18 KiB
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>,
...
}
dtag:"sovereign_browser"— namespacing so other NIP-78 apps don't collide.content: NIP-44 encrypted (to self, same pattern as bookmarks inbookmarks.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:
- Local SQLite
db_kvis the fast-path cache —shortcuts_load()reads from it for instant startup (no waiting for relays). - When the Nostr event arrives, compare its
created_atto a storedsettings.nostr_synced_attimestamp indb_kv. - If Nostr is newer: overwrite local
db_kvfrom the decrypted payload, reload in-memory bindings, reload the settings page if open. - If local is newer (user changed something offline): keep local, and schedule a re-publish so Nostr catches up.
- 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— clipboardCtrl+Z/Ctrl+Y— undo/redoCtrl+F— in-page find (only if aWebKitFindControlleris wired up; currently not)Tab/Shift+Tab— focus traversal within the pageF5/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 F1–F12 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+F5F1— 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 bygtk_accelerator_parse()on load and on eachshortcuts_set(). shortcuts_lookup()appliesgtk_accelerator_get_default_mod_mask()to the event state, then linear-scansg_parsedcomparingkeyvaland maskedmods. Returns first match (actions are distinct enough that collisions are the user's problem; the UI warns on duplicates).shortcuts_set()callsgtk_accelerator_parse(accel_str, &key, &mods); returns -1 if parse fails. On success, updatesg_parsed, writesdb_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:
- Calls
event.preventDefault()andevent.stopPropagation()so the key combo is never delivered to the page. - Reads
event.ctrlKey,event.altKey,event.shiftKey,event.metaKeyandevent.key/event.code. - Maps the JS key to a GTK accelerator string. Mapping rules:
- Modifier prefix:
<Control>/<Alt>/<Shift>/<Super>in that order. - Key normalization: letters → lowercase;
F1–F12as-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).
- Modifier prefix:
- Displays the formatted string in the row (e.g.
Ctrl+Shift+Tfor display, but sends<Control><Shift>tto the backend). - On
Enteror blur, commits viafetch('sovereign://settings/set?key=shortcut.new_tab&value=' + encodeURIComponent(accel)). Escapecancels 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_switch → shortcuts_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", pressCtrl+Q, verify it persists andCtrl+Qnow opens a new tab on next key press (same session —shortcuts_setupdates 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 = falsedisables all lookup.