/* See LICENSE file for copyright and license details */ #include #include #include #include "config.h" typedef struct { GtkWidget *window; GtkWidget *header; #if ENABLE_URL_BAR GtkWidget *url_entry; #endif GtkWidget *web_view; GtkWidget *progress; } PageApp; static void update_title(PageApp *app) { const char *title = webkit_web_view_get_title(WEBKIT_WEB_VIEW(app->web_view)); gtk_window_set_title(GTK_WINDOW(app->window), title && *title ? title : "page"); } #if ENABLE_URL_BAR static void update_url_bar(PageApp *app) { const char *uri = webkit_web_view_get_uri(WEBKIT_WEB_VIEW(app->web_view)); if (uri) gtk_editable_set_text(GTK_EDITABLE(app->url_entry), uri); } #else static void update_url_bar(PageApp *app) { (void)app; /* no bar to update when compiled without one (duh) */ } #endif static gchar *normalize_input(const char *input) { /* provided it looks like it has a scheme or a dot, treat as URL. * otherwise, treat as a query */ if (strstr(input, "://") != NULL) return g_strdup(input); if (strchr(input, ' ') == NULL && strchr(input, '.') != NULL) return g_strdup_printf("https://%s", input); gchar *escaped = g_uri_escape_string(input, NULL, FALSE); gchar *query = g_strconcat(SEARCH_URL_PREFIX, escaped, NULL); g_free(escaped); return query; } #if ENABLE_URL_BAR static void on_url_activate(GtkEntry *entry, gpointer user_data) { PageApp *app = user_data; const char *text = gtk_editable_get_text(GTK_EDITABLE(entry)); if (!text || !*text) return; gchar *uri = normalize_input(text); webkit_web_view_load_uri(WEBKIT_WEB_VIEW(app->web_view), uri); g_free(uri); gtk_widget_grab_focus(app->web_view); } #endif static void on_load_changed(WebKitWebView *web_view, WebKitLoadEvent event, gpointer user_data) { (void)web_view; PageApp *app = user_data; switch (event) { case WEBKIT_LOAD_STARTED: case WEBKIT_LOAD_REDIRECTED: gtk_widget_set_visible(app->progress, TRUE); break; case WEBKIT_LOAD_COMMITTED: update_url_bar(app); break; case WEBKIT_LOAD_FINISHED: gtk_widget_set_visible(app->progress, FALSE); update_title(app); update_url_bar(app); break; default: break; } } static void on_progress_changed(WebKitWebView *web_view, GParamSpec *pspec, gpointer user_data) { (void)pspec; PageApp *app = user_data; gdouble p = webkit_web_view_get_estimated_load_progress(web_view); gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(app->progress), p); } static void on_title_changed(WebKitWebView *web_view, GParamSpec *pspec, gpointer user_data) { (void)web_view; (void)pspec; update_title((PageApp *)user_data); } static void zoom_by(PageApp *app, double delta) { gdouble z = webkit_web_view_get_zoom_level(WEBKIT_WEB_VIEW(app->web_view)); webkit_web_view_set_zoom_level(WEBKIT_WEB_VIEW(app->web_view), z + delta); } static void zoom_reset(PageApp *app) { webkit_web_view_set_zoom_level(WEBKIT_WEB_VIEW(app->web_view), 1.0); } static void toggle_fullscreen(PageApp *app) { GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(app->window)); GdkToplevelState tstate = gdk_toplevel_get_state(GDK_TOPLEVEL(surface)); if (tstate & GDK_TOPLEVEL_STATE_FULLSCREEN) gtk_window_unfullscreen(GTK_WINDOW(app->window)); else gtk_window_fullscreen(GTK_WINDOW(app->window)); } /* scroll the loaded page's content by 'delta' pixels (negative = up). * WebKitWebView's own GtkScrollable plumbing controls the widget's * position in page's layout, not the document inside it, so this goes * through JS instead (the same thing a page's own scroll code would do) */ static void scroll_by(PageApp *app, int delta) { gchar *script = g_strdup_printf("window.scrollBy(0, %d);", delta); webkit_web_view_evaluate_javascript(WEBKIT_WEB_VIEW(app->web_view), script, -1, NULL, NULL, NULL, NULL, NULL); g_free(script); } /* run the behaviour bound to a PageAction */ static void dispatch_action(PageApp *app, PageAction action) { switch (action) { case ACTION_FOCUS_URL: #if ENABLE_URL_BAR gtk_widget_grab_focus(app->url_entry); gtk_editable_select_region(GTK_EDITABLE(app->url_entry), 0, -1); #endif break; case ACTION_QUIT: gtk_window_close(GTK_WINDOW(app->window)); break; case ACTION_RELOAD: webkit_web_view_reload(WEBKIT_WEB_VIEW(app->web_view)); break; case ACTION_RELOAD_HARD: webkit_web_view_reload_bypass_cache(WEBKIT_WEB_VIEW(app->web_view)); break; case ACTION_BACK: webkit_web_view_go_back(WEBKIT_WEB_VIEW(app->web_view)); break; case ACTION_FORWARD: webkit_web_view_go_forward(WEBKIT_WEB_VIEW(app->web_view)); break; case ACTION_ZOOM_IN: zoom_by(app, ZOOM_STEP); break; case ACTION_ZOOM_OUT: zoom_by(app, -ZOOM_STEP); break; case ACTION_ZOOM_RESET: zoom_reset(app); break; case ACTION_FULLSCREEN: toggle_fullscreen(app); break; case ACTION_ESCAPE: #if ENABLE_URL_BAR if (gtk_widget_has_focus(app->url_entry)) { update_url_bar(app); gtk_widget_grab_focus(app->web_view); } else { webkit_web_view_stop_loading(WEBKIT_WEB_VIEW(app->web_view)); } #else webkit_web_view_stop_loading(WEBKIT_WEB_VIEW(app->web_view)); #endif break; case ACTION_SCROLL_UP: scroll_by(app, -SCROLL_STEP); break; case ACTION_SCROLL_DOWN: scroll_by(app, SCROLL_STEP); break; } } /* only the modifier bits actually bind against matter for matching * (ignore lock keys, numlock, etc. that GDK may also report) */ #define RELEVANT_MODS (GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_ALT_MASK | GDK_SUPER_MASK) static gboolean on_key_pressed(GtkEventControllerKey *ctrl, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) { (void)ctrl; (void)keycode; PageApp *app = user_data; GdkModifierType mods = state & RELEVANT_MODS; for (size_t i = 0; i < N_KEYBINDINGS; i++) { if (KEYBINDINGS[i].keyval == keyval && KEYBINDINGS[i].mods == mods) { dispatch_action(app, KEYBINDINGS[i].action); return TRUE; } } return FALSE; } static gchar *expand_home(const char *path) { if (!path) return NULL; if (path[0] == '~' && (path[1] == '/' || path[1] == '\0')) { const char *home = g_get_home_dir(); return g_build_filename(home, path + 1, NULL); } return g_strdup(path); } static WebKitNetworkSession *build_network_session(void) { if (!DATA_DIR) return webkit_network_session_get_default(); gchar *data_dir = expand_home(DATA_DIR); gchar *cache_dir = g_build_filename(data_dir, "cache", NULL); g_mkdir_with_parents(data_dir, 0700); g_mkdir_with_parents(cache_dir, 0700); WebKitNetworkSession *session = webkit_network_session_new(data_dir, cache_dir); gchar *cookie_file = g_build_filename(data_dir, "cookies.sqlite", NULL); WebKitCookieManager *cookie_mgr = webkit_network_session_get_cookie_manager(session); webkit_cookie_manager_set_persistent_storage(cookie_mgr, cookie_file, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE); g_free(cookie_file); g_free(data_dir); g_free(cache_dir); return session; } static void activate(GtkApplication *gtk_app, gpointer user_data) { const char *start_uri = user_data ? (const char *)user_data : DEFAULT_URI; PageApp *app = g_new0(PageApp, 1); app->window = gtk_application_window_new(gtk_app); gtk_window_set_default_size(GTK_WINDOW(app->window), WINDOW_WIDTH, WINDOW_HEIGHT); #if ENABLE_URL_BAR app->header = gtk_header_bar_new(); gtk_header_bar_set_show_title_buttons(GTK_HEADER_BAR(app->header), TRUE); app->url_entry = gtk_entry_new(); gtk_widget_set_hexpand(app->url_entry, TRUE); gtk_editable_set_width_chars(GTK_EDITABLE(app->url_entry), 40); g_signal_connect(app->url_entry, "activate", G_CALLBACK(on_url_activate), app); gtk_header_bar_set_title_widget(GTK_HEADER_BAR(app->header), app->url_entry); gtk_window_set_titlebar(GTK_WINDOW(app->window), app->header); #else app->header = NULL; /* no bar built (window uses GTK's default titlebar) */ #endif GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); app->progress = gtk_progress_bar_new(); gtk_widget_set_visible(app->progress, FALSE); gtk_box_append(GTK_BOX(box), app->progress); /* cookies/cache/history/logins/whatever persist under DATA_DIR */ WebKitNetworkSession *session = build_network_session(); app->web_view = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW, "network-session", session, NULL)); gtk_widget_set_vexpand(app->web_view, TRUE); gtk_widget_set_hexpand(app->web_view, TRUE); WebKitSettings *settings = webkit_web_view_get_settings(WEBKIT_WEB_VIEW(app->web_view)); webkit_settings_set_enable_developer_extras(settings, ENABLE_DEV_TOOLS); g_signal_connect(app->web_view, "load-changed", G_CALLBACK(on_load_changed), app); g_signal_connect(app->web_view, "notify::estimated-load-progress", G_CALLBACK(on_progress_changed), app); g_signal_connect(app->web_view, "notify::title", G_CALLBACK(on_title_changed), app); gtk_box_append(GTK_BOX(box), app->web_view); gtk_window_set_child(GTK_WINDOW(app->window), box); GtkEventController *key_ctrl = gtk_event_controller_key_new(); g_signal_connect(key_ctrl, "key-pressed", G_CALLBACK(on_key_pressed), app); gtk_widget_add_controller(app->window, key_ctrl); webkit_web_view_load_uri(WEBKIT_WEB_VIEW(app->web_view), start_uri); #if ENABLE_URL_BAR gtk_editable_set_text(GTK_EDITABLE(app->url_entry), start_uri); #endif gtk_widget_set_visible(app->window, TRUE); gtk_widget_grab_focus(app->web_view); } static void print_usage(const char *progname) { g_print( "Usage: %s [OPTIONS] [URL | SEARCH TERMS]\n" "\n" "A minimal WebKitGTK viewer\n" "\n" " With no argument, opens DEFAULT_URI (see config.h).\n" " With a URL-like argument, opens it directly.\n" " With anything else, searches via SEARCH_URL_PREFIX (see config.h).\n" "\n" "Options:\n" " -h, --help Show this help and exit\n" "\n" "Keybindings, window size, data directory, and search engine are\n" "all configured at compile time in config.h (thanks, captain obvious).\n", progname); } int main(int argc, char **argv) { for (int i = 1; i < argc; i++) { if (g_strcmp0(argv[i], "-h") == 0 || g_strcmp0(argv[i], "--help") == 0) { print_usage(argv[0]); return 0; } } gchar *start_uri = (argc > 1) ? normalize_input(argv[1]) : g_strdup(DEFAULT_URI); GtkApplication *gtk_app = gtk_application_new("dev.page.viewer", G_APPLICATION_DEFAULT_FLAGS); g_signal_connect(gtk_app, "activate", G_CALLBACK(activate), start_uri); int status = g_application_run(G_APPLICATION(gtk_app), 1, argv); g_object_unref(gtk_app); g_free(start_uri); return status; }