commit 41b87a9457e6d12868674fff64d9bfef91719912 Author: tsukasa Date: Wed Aug 12 18:48:46 2026 +0800 init diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..286f5f5 --- /dev/null +++ b/.hgignore @@ -0,0 +1,4 @@ +syntax: glob +build/ + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d36b2ae --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# page + +A minimal WebKitGTK viewer. Inspired by [surf](https://surf.suckless.org/), +but without the XEmbed dependency, so it actually works under pure Wayland. + +## Dependencies + +- `pkg-config` +- `meson` +- `ninja` +- `wrapGAppsHook3` +- `gtk4` +- `webkitgtk_6_0` +- `glib-networking` +- `gsettings-desktop-schemas` +- `cacert` +- `gstreamer` +- `gst-plugins-base` +- `gst-plugins-good` +- `gst-plugins-bad` +- `gst-plugins-ugly` +- `gst-libav` + + +## Building + +```sh +nix develop (on NixOS) +meson setup build +ninja -C build +./build/page +``` + +Or, for a standalone wrapped binary (nixOS only): + +```sh +nix build +./result/bin/page +``` + +## Usage + +```rc +page +page example.com +page some search terms # searches Google by default +page -h # help page +``` + +See `config.h` for keybindings, default URI, data directory, and other +tweakable settings and stuff. diff --git a/config.h b/config.h new file mode 100644 index 0000000..a45c919 --- /dev/null +++ b/config.h @@ -0,0 +1,80 @@ +#ifndef PAGE_CONFIG_H +#define PAGE_CONFIG_H + +#include + +/* Page loaded on startup if no URL is given on the command line, + * very self-explanatory*/ +static const char *DEFAULT_URI = "https://google.com/"; + +/* Your search engine of choice */ +static const char *SEARCH_URL_PREFIX = "https://google.com/search?q="; + +/* Default window size */ +static const int WINDOW_WIDTH = 1200; +static const int WINDOW_HEIGHT = 800; + +/* Fraction added/removed per zoom keypress. */ +static const double ZOOM_STEP = 0.1; + +/* Pixels scrolled per Ctrl+Shift+J/K press. */ +static const int SCROLL_STEP = 120; + +/* Path in which cookies, INdexedDB, localStorage, HTTP cache, + * blah blah blah are stored. NULL for the default WebKit XDG path */ +static const char *DATA_DIR = "~/.local/share/page"; + +static const gboolean ENABLE_DEV_TOOLS = TRUE; + +/* To URL bar or to not - that is the question */ +#define ENABLE_URL_BAR 1 + +/* keybinds */ +typedef enum { + ACTION_FOCUS_URL, + ACTION_QUIT, + ACTION_RELOAD, + ACTION_RELOAD_HARD, + ACTION_BACK, + ACTION_FORWARD, + ACTION_ZOOM_IN, + ACTION_ZOOM_OUT, + ACTION_ZOOM_RESET, + ACTION_FULLSCREEN, + ACTION_ESCAPE, + ACTION_SCROLL_UP, + ACTION_SCROLL_DOWN, +} PageAction; + +typedef struct { + guint keyval; + GdkModifierType mods; + PageAction action; +} PageKeybind; + +static const PageKeybind KEYBINDINGS[] = { + /* keyval modifiers action */ +#if ENABLE_URL_BAR + { GDK_KEY_l, GDK_CONTROL_MASK, ACTION_FOCUS_URL }, +#endif + { GDK_KEY_q, GDK_CONTROL_MASK, ACTION_QUIT }, + { GDK_KEY_r, GDK_CONTROL_MASK, ACTION_RELOAD }, + { GDK_KEY_R, GDK_CONTROL_MASK | GDK_SHIFT_MASK, ACTION_RELOAD_HARD }, + { GDK_KEY_F5, 0, ACTION_RELOAD }, + { GDK_KEY_Left, GDK_ALT_MASK, ACTION_BACK }, + { GDK_KEY_Right, GDK_ALT_MASK, ACTION_FORWARD }, + { GDK_KEY_plus, GDK_CONTROL_MASK, ACTION_ZOOM_IN }, + { GDK_KEY_equal, GDK_CONTROL_MASK, ACTION_ZOOM_IN }, + { GDK_KEY_minus, GDK_CONTROL_MASK, ACTION_ZOOM_OUT }, + { GDK_KEY_0, GDK_CONTROL_MASK, ACTION_ZOOM_RESET }, + { GDK_KEY_F11, 0, ACTION_FULLSCREEN }, + { GDK_KEY_Escape, 0, ACTION_ESCAPE }, + { GDK_KEY_H, GDK_CONTROL_MASK | GDK_SHIFT_MASK, ACTION_BACK }, + { GDK_KEY_L, GDK_CONTROL_MASK | GDK_SHIFT_MASK, ACTION_FORWARD }, + { GDK_KEY_J, GDK_CONTROL_MASK | GDK_SHIFT_MASK, ACTION_SCROLL_DOWN }, + { GDK_KEY_K, GDK_CONTROL_MASK | GDK_SHIFT_MASK, ACTION_SCROLL_UP }, +}; + +static const size_t N_KEYBINDINGS = sizeof(KEYBINDINGS) / sizeof(KEYBINDINGS[0]); + +#endif diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7c238b7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,74 @@ +{ + description = "page - a gtk view thingy whatever fuck you"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" (with pkgs.gst_all_1; [ + gstreamer + gst-plugins-base + gst-plugins-good + gst-plugins-bad + gst-plugins-ugly + gst-libav + ]); + + page = pkgs.stdenv.mkDerivation { + pname = "page"; + version = "0.1.0"; + src = ./.; + + nativeBuildInputs = [ + pkgs.pkg-config + pkgs.meson + pkgs.ninja + pkgs.wrapGAppsHook3 + ]; + buildInputs = [ + pkgs.gtk4 + pkgs.webkitgtk_6_0 + pkgs.glib-networking + pkgs.gsettings-desktop-schemas + pkgs.cacert + ]; + + preFixup = '' + gappsWrapperArgs+=( + --prefix GST_PLUGIN_SYSTEM_PATH_1_0 : "${gstPluginPath}" + --set SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + --set GST_REGISTRY_REUSE_PLUGIN_SCANNER no + ) + ''; + }; + in + { + packages.default = page; + packages.page = page; + + apps.default = { + type = "app"; + program = "${page}/bin/page"; + }; + + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ pkgs.pkg-config pkgs.meson pkgs.ninja pkgs.gdb ]; + buildInputs = [ pkgs.gtk4 pkgs.webkitgtk_6_0 pkgs.glib-networking pkgs.cacert ] + ++ (with pkgs.gst_all_1; [ + gstreamer gst-plugins-base gst-plugins-good + gst-plugins-bad gst-plugins-ugly gst-libav + ]); + GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules"; + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + GST_PLUGIN_SYSTEM_PATH_1_0 = gstPluginPath; + shellHook = '' + ''; + }; + }); +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..89766d0 --- /dev/null +++ b/meson.build @@ -0,0 +1,20 @@ +project( + 'page', + 'c', + version: '0.1.0', + default_options: [ + 'c_std=c11', + 'warning_level=2', + 'buildtype=debugoptimized', + ], +) + +gtk4_dep = dependency('gtk4') +webkitgtk_dep = dependency('webkitgtk-6.0') + +executable( + 'page', + 'page.c', + dependencies: [gtk4_dep, webkitgtk_dep], + install: true, +) diff --git a/page.c b/page.c new file mode 100644 index 0000000..5feeeae --- /dev/null +++ b/page.c @@ -0,0 +1,333 @@ +/* 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; +}