Files
sovereign_browser/plans/keyboard-shortcuts.md

18 KiB
Raw Permalink Blame History

Plan: Configurable Keyboard Shortcuts

Overview

Make browser-level keyboard shortcuts user-configurable on the sovereign://settings page. The user presses the desired key combination directly in the page (no typing "ctrl-F1" into a text box); the page captures the physical key press, formats it, and persists it. On the C side, the existing hardcoded on_key_press() dispatcher in main.c is refactored to look up bindings from a new shortcuts module.

Nostr sync (NIP-78, kind 30078)

Per NIP-78, kind 30078 is an addressable event for arbitrary custom app data — its first listed use case is literally "User personal settings on Nostr clients." We use it to sync all sovereign_browser user settings (not just shortcuts) across devices.

Event design

{
  "kind": 30078,
  "pubkey": "<user pubkey hex>",
  "tags": [
    ["d", "sovereign_browser"],
    ["client", "sovereign_browser"]
  ],
  "content": "<NIP-44 encrypted JSON object of all user settings>",
  "created_at": <unix timestamp>,
  ...
}
  • d tag: "sovereign_browser" — namespacing so other NIP-78 apps don't collide.
  • content: NIP-44 encrypted (to self, same pattern as bookmarks in bookmarks.c) JSON object containing all user-configurable settings: shortcuts bindings, tab preferences, agent server config, bootstrap relays, security toggles. This keeps potentially sensitive config (relay list, origins) private.
  • Plaintext payload (before encryption):
    {
      "shortcuts": {
        "new_tab": "<Control>t",
        "close_tab": "<Control>w",
        ...
      },
      "settings": {
        "restore_session": true,
        "new_tab_url": "https://example.com",
        "tab_bar_position": "top",
        ...
      }
    }
    

Sync flow

flowchart TD
    A["User changes shortcut in settings UI"] --> B["shortcuts_set action accel"]
    B --> C["Update in-memory g_parsed"]
    B --> D["db_kv_set shortcut.X"]
    D --> E["Debounced publish 500ms"]
    E --> F["Serialize all settings to JSON"]
    F --> G["NIP-44 encrypt to self"]
    G --> H["Build kind 30078 event d=sovereign_browser"]
    H --> I["Sign with signer"]
    I --> J["Publish to bootstrap relays"]
    I --> K["Store in SQLite db_store_event"]

    L["Browser startup / login"] --> M["relay_fetch_thread fetches kind 30078"]
    M --> N["Store in SQLite"]
    N --> O["shortcuts_load_from_nostr: decrypt + parse"]
    O --> P["Merge: Nostr values override local defaults, local overrides win on conflict timestamp"]
    P --> Q["Populate in-memory g_parsed + write db_kv cache"]

Merge / conflict policy

On startup, after the relay fetch thread completes (existing relay_fetch_thread already fetches kinds 0/3/10002 — we add 30078), we decrypt the latest kind 30078 d=sovereign_browser event and merge:

  1. Local SQLite db_kv is the fast-path cacheshortcuts_load() reads from it for instant startup (no waiting for relays).
  2. When the Nostr event arrives, compare its created_at to a stored settings.nostr_synced_at timestamp in db_kv.
  3. If Nostr is newer: overwrite local db_kv from the decrypted payload, reload in-memory bindings, reload the settings page if open.
  4. If local is newer (user changed something offline): keep local, and schedule a re-publish so Nostr catches up.
  5. First run / no Nostr event: defaults are used; nothing published until the user changes something.

This mirrors how bookmarks work but is simpler (single event vs. one-per-directory).

Read-only / no-login mode

When there's no signer (read-only or --no-login mode), NIP-44 encryption isn't available. In that case:

  • Settings still work fully via local db_kv (the fast path).
  • No kind 30078 event is published or fetched.
  • The settings page shows a note: "Nostr sync disabled — log in with a signing key to sync settings across devices."

What goes in the Nostr payload

Setting Synced via NIP-78? Rationale
Shortcut bindings Yes User preference, device-independent
Tab bar position, close buttons, drag reorder Yes UI preference
new_tab_url, max_tabs Yes Preference
restore_session No Device-specific (which tabs were open)
agent_server_enabled, agent_server_port, agent_allowed_origins No Device-specific (port may differ, origins may be localhost)
agent_login_timeout_ms No Device-specific
bootstrap_relays Yes User's relay list should follow them
Security toggles (dev_extras, file_access, universal_access) No Device-specific debugging flags

The settings_sync.c module (see below) knows which keys belong in the Nostr payload via a whitelist.

WebKitGTK built-in shortcuts (research finding)

WebKitGTK webviews consume standard editing shortcuts at the webview level before they reach our window-level key-press-event handler:

  • Ctrl+C / Ctrl+V / Ctrl+X / Ctrl+A — clipboard
  • Ctrl+Z / Ctrl+Y — undo/redo
  • Ctrl+F — in-page find (only if a WebKitFindController is wired up; currently not)
  • Tab / Shift+Tab — focus traversal within the page
  • F5 / Ctrl+R — reload (WebKit handles these for the webview)

Browser-level shortcuts (tab management, URL focus, tab cycling) are not handled by WebKit — they are ours, currently hardcoded in on_key_press(). These are the ones we make configurable.

Conflict policy: If a user assigns a binding that WebKit consumes for the focused webview (e.g. Ctrl+C), the webview eats it and our handler never sees it. The settings UI will warn when a chosen combo is in a "reserved by web content" list, but will still allow it (the user may want it to apply only when the URL bar / tab strip has focus). Recommended defaults stick to Ctrl+ letter combos and F1F12 which are safe.

Binding format

Use GTK accelerator string format (parseable by gtk_accelerator_parse):

  • <Control>t — Ctrl+T
  • <Control><Shift>Tab — Ctrl+Shift+Tab
  • <Control>F5 — Ctrl+F5
  • F1 — bare function key

This gives us free parsing/serialization via gtk_accelerator_parse() / gtk_accelerator_name() and keeps the stored representation compact and human-readable.

Action registry

A fixed enum of browser actions the user can bind. Each has: id, display label, description, default binding.

Action ID Label Default binding
new_tab New tab <Control>t
close_tab Close tab <Control>w
focus_url Focus URL bar <Control>l
next_tab Next tab <Control>Tab
prev_tab Previous tab <Control><Shift>Tab
next_tab_pagedown Next tab (PageDown) <Control>Page_Down
prev_tab_pageup Previous tab (PageUp) <Control>Page_Up
reload Reload page F5
force_reload Force reload <Shift>F5
go_back Go back <Alt>Left
go_forward Go forward <Alt>Right
find Find in page <Control>f
open_settings Open settings page <Control>comma
new_identity Switch identity <Control><Shift>i
toggle_fullscreen Toggle fullscreen F11

(Exact list is adjustable; the registry is a single table in shortcuts.c so adding/removing actions is trivial.)

Storage

Each binding is one row in the existing key_value table:

  • key: shortcut.<action_id> (e.g. shortcut.new_tab)
  • value: accelerator string (e.g. <Control>t)

Loaded once at startup into an in-memory array indexed by action enum. settings_save() is not extended — the shortcuts module owns its own load/save via db_kv_get/db_kv_set.

New module: shortcuts.h / shortcuts.c

flowchart LR
    A["db_kv_get shortcut.*"] --> B["shortcuts_load"]
    B --> C["g_bindings array"]
    D["on_key_press event"] --> E["shortcuts_lookup event"]
    C --> E
    E --> F["action enum or -1"]
    F --> G["dispatch switch in main.c"]
    H["sovereign://settings/set?key=shortcut.X"] --> I["shortcuts_set action accel"]
    I --> J["db_kv_set + update g_bindings"]

shortcuts.h (public API)

typedef enum {
    SHORTCUT_NEW_TAB = 0,
    SHORTCUT_CLOSE_TAB,
    SHORTCUT_FOCUS_URL,
    SHORTCUT_NEXT_TAB,
    SHORTCUT_PREV_TAB,
    SHORTCUT_NEXT_TAB_PAGEDOWN,
    SHORTCUT_PREV_TAB_PAGEUP,
    SHORTCUT_RELOAD,
    SHORTCUT_FORCE_RELOAD,
    SHORTCUT_GO_BACK,
    SHORTCUT_GO_FORWARD,
    SHORTCUT_FIND,
    SHORTCUT_OPEN_SETTINGS,
    SHORTCUT_NEW_IDENTITY,
    SHORTCUT_TOGGLE_FULLSCREEN,
    SHORTCUT_COUNT
} shortcut_action_t;

/* Load all bindings from db_kv_get into the in-memory array.
 * Call once at startup after db_init(). Missing keys use defaults. */
void  shortcuts_load(void);

/* Look up which action a key event matches, or -1 if none.
 * Parses the stored accelerator strings once (cached) and compares
 * keyval + mods. */
int   shortcuts_lookup(GdkEventKey *event);

/* Get the accelerator string for an action (e.g. for the settings UI). */
const char *shortcuts_get(shortcut_action_t action);

/* Set and persist a new binding for an action.
 * accel_str must be parseable by gtk_accelerator_parse.
 * Returns 0 on success, -1 on parse failure. */
int   shortcuts_set(shortcut_action_t action, const char *accel_str);

/* Reset an action to its default binding (and persist). */
void  shortcuts_reset(shortcut_action_t action);

/* Reset all actions to defaults (and persist). */
void  shortcuts_reset_all(void);

/* Metadata for the settings UI: label, description, default accel. */
typedef struct {
    const char *id;       /* "new_tab" */
    const char *label;    /* "New tab" */
    const char *desc;     /* "Open a new tab" */
    const char *dflt;     /* "<Control>t" */
} shortcut_meta_t;
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);

shortcuts.c (implementation notes)

  • Static array static struct { guint keyval; GdkModifierType mods; } g_parsed[SHORTCUT_COUNT]; populated by gtk_accelerator_parse() on load and on each shortcuts_set().
  • shortcuts_lookup() applies gtk_accelerator_get_default_mod_mask() to the event state, then linear-scans g_parsed comparing keyval and masked mods. Returns first match (actions are distinct enough that collisions are the user's problem; the UI warns on duplicates).
  • shortcuts_set() calls gtk_accelerator_parse(accel_str, &key, &mods); returns -1 if parse fails. On success, updates g_parsed, writes db_kv_set("shortcut.<id>", accel_str).

Refactor on_key_press() in main.c

Replace the hardcoded if/else chain with:

static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) {
    int action = shortcuts_lookup(event);
    if (action < 0) return FALSE;

    switch ((shortcut_action_t)action) {
    case SHORTCUT_NEW_TAB:    tab_manager_new_tab(NULL); return TRUE;
    case SHORTCUT_CLOSE_TAB:  tab_manager_close_active(); return TRUE;
    case SHORTCUT_FOCUS_URL:  /* ... existing Ctrl+L logic ... */ return TRUE;
    case SHORTCUT_NEXT_TAB:   tab_manager_next(); return TRUE;
    case SHORTCUT_PREV_TAB:   tab_manager_prev(); return TRUE;
    /* ... etc ... */
    case SHORTCUT_FIND:       /* open find bar (new) */ return TRUE;
    case SHORTCUT_OPEN_SETTINGS: on_menu_settings(NULL, NULL); return TRUE;
    /* ... */
    default: return FALSE;
    }
}

The ctrl_tab_switch boolean setting is removed from the settings struct (or kept as a quick on/off for the whole shortcuts feature). Decision: keep ctrl_tab_switch as a master "enable keyboard shortcuts" toggle — when off, shortcuts_lookup() returns -1 immediately. This preserves the existing one-click disable behavior.

Settings page UI — "Keyboard Shortcuts" section

Added to handle_settings_page() HTML, between "Tabs" and "Agent Server". For each action in the registry, render a row:

[ New tab                ]  [ <Control>t        ] [ Change ] [ Reset ]
[ Close tab              ]  [ <Control>w        ] [ Change ] [ Reset ]
...
[ Reset all to defaults ]

Key-capture interaction (the "press the keys" UX)

Each row's Change button focuses a hidden capture <input> and shows "Press keys…". The input has a keydown listener that:

  1. Calls event.preventDefault() and event.stopPropagation() so the key combo is never delivered to the page.
  2. Reads event.ctrlKey, event.altKey, event.shiftKey, event.metaKey and event.key / event.code.
  3. Maps the JS key to a GTK accelerator string. Mapping rules:
    • Modifier prefix: <Control> / <Alt> / <Shift> / <Super> in that order.
    • Key normalization: letters → lowercase; F1F12 as-is; special keys (Tab, Page_Down, Page_Up, Left, Right, comma, space) mapped to GDK key names.
    • Bare modifier-only presses are ignored (wait for a real key).
  4. Displays the formatted string in the row (e.g. Ctrl+Shift+T for display, but sends <Control><Shift>t to the backend).
  5. On Enter or blur, commits via fetch('sovereign://settings/set?key=shortcut.new_tab&value=' + encodeURIComponent(accel)).
  6. Escape cancels capture without committing.

A small JS lookup table maps JS event.code/event.key → GDK key name for the ~20 keys we care about. This keeps the capture logic in the page without needing a round-trip to C for each keypress.

Backend handling in handle_settings_set()

Add a branch in the key/value path of handle_settings_set():

if (strncmp(key, "shortcut.", 9) == 0) {
    const char *action_id = key + 9;
    /* find action by id string in the registry */
    shortcut_action_t a = shortcuts_action_from_id(action_id);
    if (a < 0) { respond_error_json(...); return; }
    if (shortcuts_set(a, value) != 0) {
        respond_error_json(request, -1, "Invalid key combination");
        return;
    }
    /* respond with ok + reload */
}

Also add a shortcut.reset and shortcut.reset_all pseudo-keys (or a separate sovereign://settings/shortcut-reset?action=X endpoint) for the Reset buttons.

Conflict / duplicate warnings

The settings page JS, after a successful commit, can fetch all current bindings (embedded in the page HTML on render) and highlight any other action that now shares the same accelerator string. The backend shortcuts_set() does not reject duplicates — last-wins at lookup time — but the UI flags them in red so the user notices.

Files to create / modify

File Change
src/shortcuts.h New — action enum + public API
src/shortcuts.c New — registry, load/save (from db_kv), lookup, parse
src/settings_sync.h New — NIP-78 sync API: settings_sync_init(), settings_sync_publish(), settings_sync_merge_from_nostr()
src/settings_sync.c New — serialize whitelisted settings + shortcuts to JSON, NIP-44 encrypt to self, build/sign kind 30078 d=sovereign_browser, publish to relays, store in SQLite; decrypt + merge on fetch. Reuses the pattern from bookmarks.c publish_directory().
src/main.c Refactor on_key_press() to dispatch via shortcuts_lookup(); call shortcuts_load() after settings_load(); call settings_sync_init() after login; add action handlers (find, settings, fullscreen, back/forward)
src/nostr_bridge.c Add "Keyboard Shortcuts" section to handle_settings_page() HTML + key-capture JS; add shortcut.* branch to handle_settings_set(); trigger debounced settings_sync_publish() on any settings change
src/settings.h / settings.c Repurpose ctrl_tab_switchshortcuts_enabled master toggle (or keep name + add alias); update defaults + load/save
src/relay_fetch.c / relay_fetch.h Add kind 30078 to the fetched kinds; after fetch, call settings_sync_merge_from_nostr() to decrypt and merge
Makefile Add src/shortcuts.c and src/settings_sync.c to SRC
src/tab_manager.h / tab_manager.c Add tab_manager_find() / find-bar helpers if SHORTCUT_FIND is implemented in this phase (can be stubbed for later)

Phasing

Phase 1a (local): shortcuts module + refactor on_key_press + settings UI with key capture. Covers all currently-hardcoded actions plus reload, go_back, go_forward, open_settings, toggle_fullscreen (these already have tab_manager/menu functions to call). Persistence via db_kv only.

Phase 1b (Nostr sync): settings_sync module — publish kind 30078 on debounced settings changes, fetch + merge on startup. This makes shortcuts (and other whitelisted settings) sync across devices.

Phase 2 (future): find (requires wiring WebKitFindController + a find bar widget), new_identity (calls app_menu_switch_identity_proxy), force_reload (bypass cache).

Testing

  • Manual: open sovereign://settings, press Change on "New tab", press Ctrl+Q, verify it persists and Ctrl+Q now opens a new tab on next key press (same session — shortcuts_set updates the in-memory array immediately).
  • Restart browser, verify binding persisted in SQLite.
  • Reset single + reset all buttons restore defaults.
  • Duplicate-binding warning appears when two actions share a combo.
  • Bare modifier press (just Ctrl) doesn't commit; Escape cancels.
  • shortcuts_enabled = false disables all lookup.