v0.0.15 - Added search engine integration with URL bar dropdown: DuckDuckGo default, direct links first from history/bookmarks/domain heuristic, async search suggestions, Tab key navigation through dropdown, configurable engine in settings (synced via NIP-78)

This commit is contained in:
Laan Tungir
2026-07-12 15:47:35 -04:00
parent f91ff74138
commit f01a345b7c
22 changed files with 3206 additions and 96 deletions

View File

@@ -17,7 +17,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c
$(BIN): $(SRC) $(NOSTR_LIB)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)

View File

@@ -1 +1 @@
0.0.14
0.0.15

342
plans/keyboard-shortcuts.md Normal file
View File

@@ -0,0 +1,342 @@
# 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`](src/main.c:230) is refactored to look up bindings from a new `shortcuts` module.
## Nostr sync (NIP-78, kind 30078)
Per [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md), 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`](src/bookmarks.c:162)) 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):
```json
{
"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
```mermaid
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`](src/relay_fetch.c) 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 cache** — `shortcuts_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()`](src/main.c:230). 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+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`
```mermaid
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)
```c
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:
```c
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()`](src/nostr_bridge.c:393) 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; `F1``F12` 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()`](src/nostr_bridge.c:705):
```c
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`](src/bookmarks.c:214) `publish_directory()`. |
| `src/main.c` | Refactor [`on_key_press()`](src/main.c:230) 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()`](src/nostr_bridge.c:393) HTML + key-capture JS; add `shortcut.*` branch to [`handle_settings_set()`](src/nostr_bridge.c:705); 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", 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.

127
plans/search-engine.md Normal file
View File

@@ -0,0 +1,127 @@
# Search Engine Integration & URL Bar Dropdown
## Goal
Integrate a search engine (DuckDuckGo default, user-changeable) into the URL bar. When the user types, a dropdown appears with **direct links first** (from history, bookmarks, and domain heuristics), followed by **search engine suggestions**. Selecting a direct link navigates to that URL; selecting a search suggestion goes to the search engine results page.
## Architecture
```mermaid
flowchart TD
A[User types in URL entry] --> B[on_url_changed callback]
B --> C[Clear suggestion list store]
C --> D[db_history_search query]
C --> E[bookmarks_search query]
C --> F[domain heuristic]
D --> G[Add direct-link rows to model]
E --> G
F --> G
B --> H[Async SoupRequest to DDG ac API]
H --> I[Add search-suggestion rows to model]
G --> J[GtkEntryCompletion dropdown shows]
I --> J
J --> K{User selects a row}
K -->|direct link| L[webkit_web_view_load_uri url]
K -->|search suggestion| M[load search engine URL with query]
K -->|Enter, no selection| N{Is input a URL?}
N -->|Yes| L
N -->|No| M
```
## Data Sources for Direct Links
1. **History** — SQLite `history` table, searched by URL/title substring, ranked by `visit_count DESC, visited_at DESC`. These are sites the user has actually visited.
2. **Bookmarks** — In-memory bookmark list, searched by URL/title substring.
3. **Domain heuristic** — If the typed text has no spaces and no dots, offer `https://<text>.org` and `https://<text>.com` as "Navigate directly" options. Handles first-time visits to known domains.
## Search Engine Suggestions
DuckDuckGo autocomplete API: `https://duckduckgo.com/ac/?q=<query>&type=list`
Returns a JSON array: `["wikipedia", "wikipedia english", ...]`
Fetched asynchronously via libsoup (already linked). Results populate the lower section of the dropdown. Selecting one navigates to `https://duckduckgo.com/?q=<term>`.
## Search Engine Configuration
Built-in engines hardcoded in `search.c`:
| Engine | Search URL | Suggestion URL |
|--------|-----------|----------------|
| DuckDuckGo | `https://duckduckgo.com/?q=%s` | `https://duckduckgo.com/ac/?q=%s&type=list` |
| Google | `https://www.google.com/search?q=%s` | `https://suggestqueries.google.com/complete/search?client=firefox&q=%s` |
| Brave | `https://search.brave.com/search?q=%s` | `https://search.brave.com/api/suggest?q=%s` |
| Startpage | `https://www.startpage.com/sp/search?query=%s` | (none — no autocomplete) |
| Searx | `https://searx.be/search?q=%s` | (none) |
The selected engine name is stored in settings (`search_engine` key) and synced via NIP-78.
## Files to Create
### `src/search.h` + `src/search.c`
- `search_engine_t` struct: name, search_url_template, suggestion_url_template
- `search_engines_get()` — returns array of built-in engines
- `search_engine_get_active()` — returns the currently selected engine
- `search_engine_build_search_url(const char *query)` — builds a search URL from the active engine's template
- `search_engine_build_suggestion_url(const char *query)` — builds the suggestion API URL
- `search_suggest_fetch_async(const char *query, GFunc callback, gpointer user_data)` — async libsoup HTTP GET, parses JSON array, calls callback with results
- `search_is_url(const char *input)` — heuristic: returns TRUE if input looks like a URL (has scheme, or has a dot with no spaces)
## Files to Modify
### `src/settings.h` + `src/settings.c`
- Add `char search_engine[64]` to `browser_settings_t` (default "duckduckgo")
- Load/save the `search_engine` key from db_kv
### `src/db.h` + `src/db.c`
- Add `db_history_search(const char *query, char ***urls_out, char ***titles_out, int *count_out, int limit)``WHERE url LIKE '%q%' OR title LIKE '%q%' ORDER BY visit_count DESC, visited_at DESC LIMIT ?`
- Add index on `history(url)` and `history(title)` for search performance
### `src/tab_manager.c`
- Add a `GtkListStore` (per-tab or shared) with columns: display text, URL, item type (direct/suggestion), icon name
- Add `GtkEntryCompletion` to each tab's `url_entry` with a custom match function
- Add `on_url_changed` callback — rebuilds the list store on each keystroke:
1. Clear store
2. Query history via `db_history_search()`
3. Query bookmarks via `bookmarks_get_dirs()` + filter
4. Add domain heuristic entries if applicable
5. Fire async `search_suggest_fetch_async()` — on completion, append suggestion rows
- Override `match-selected` signal — read the URL column; if it's a direct link, navigate; if it's a search suggestion, build search URL and navigate
- Modify `on_url_activate` — if input is not a URL (per `search_is_url()`), build a search URL and navigate instead of prepending `https://`
- Modify `normalize_url` — if input is not a URL and not an about: URL, treat as a search query
### `src/nostr_bridge.c`
- Add a "Search Engine" section to the settings page HTML with a `<select>` dropdown of built-in engines
- Add `search_engine` to the `handle_settings_set` key/value handler
### `src/settings_sync.c`
- Add `"search_engine"` to `g_sync_setting_keys[]` whitelist
### `Makefile`
- Add `src/search.c` to `SRC`
## Dropdown UX Design
Each row in the completion dropdown shows:
- **Direct links** (history/bookmarks/domain): `🌐 wikipedia.org — Wikipedia` (URL + title), with a history/bookmark icon
- **Search suggestions**: `🔍 wikipedia english` (search term), with a search icon
Direct links always appear before search suggestions. A visual separator (different icon, possibly different text color via Pango markup) distinguishes the two categories.
## Enter Key Behavior
When the user presses Enter without selecting a dropdown item:
1. If `search_is_url(input)` is true → navigate to the URL (current behavior)
2. If not → build a search URL from the active engine and navigate
This means typing "wikipedia" and pressing Enter goes to DDG search, but typing "wikipedia.org" and pressing Enter goes directly to wikipedia.org. Typing "wikipedia" and selecting the history entry for wikipedia.org from the dropdown goes directly there.
## Implementation Order
1. Create `src/search.h` + `src/search.c` (engine definitions, URL builders, async suggestion fetch, URL heuristic)
2. Add `db_history_search()` to `src/db.h` + `src/db.c` + search indexes
3. Add `search_engine` setting to `src/settings.h` + `src/settings.c`
4. Add search engine dropdown to settings page in `src/nostr_bridge.c`
5. Add `search_engine` to sync whitelist in `src/settings_sync.c`
6. Implement URL bar dropdown in `src/tab_manager.c` (list store, entry completion, changed callback, match-selected handler)
7. Update `on_url_activate` / `normalize_url` for search fallback
8. Add `src/search.c` to `Makefile`
9. Build and test

View File

@@ -13,6 +13,7 @@
#include "agent_server.h"
#include "tab_manager.h"
#include "settings.h"
#include "search.h"
#include <string.h>
#include <stdlib.h>
@@ -59,8 +60,15 @@ static gboolean get_bool_param(cJSON *params, const char *key, gboolean fallback
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') return NULL;
if (strstr(input, "://") != NULL) return g_strdup(input);
return g_strdup_printf("https://%s", input);
/* If it looks like a URL, normalize it. */
if (search_is_url(input)) {
if (strstr(input, "://") != NULL) return g_strdup(input);
if (strncmp(input, "about:", 6) == 0) return g_strdup(input);
if (strncmp(input, "sovereign://", 12) == 0) return g_strdup(input);
return g_strdup_printf("https://%s", input);
}
/* Not a URL — treat as a search query. */
return search_build_search_url(input);
}
/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */

View File

@@ -81,6 +81,12 @@ static const char *SCHEMA_SQL =
");"
"CREATE INDEX IF NOT EXISTS idx_history_visited_at "
" ON history(visited_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_history_url "
" ON history(url);"
"CREATE INDEX IF NOT EXISTS idx_history_title "
" ON history(title);"
"CREATE INDEX IF NOT EXISTS idx_history_visit_count "
" ON history(visit_count DESC);"
"CREATE TABLE IF NOT EXISTS session ("
" tab_index INTEGER PRIMARY KEY,"
" url TEXT NOT NULL,"
@@ -494,6 +500,68 @@ int db_history_clear(void) {
return 0;
}
/* ── History search ────────────────────────────────────────────────── */
int db_history_search(const char *query,
char ***urls_out, char ***titles_out,
int *count_out, int limit) {
if (g_db == NULL || query == NULL || urls_out == NULL || count_out == NULL)
return -1;
if (limit <= 0) limit = 10;
/* Build a LIKE pattern: %query% (case-insensitive via COLLATE NOCASE
* is not needed — SQLite LIKE is case-insensitive for ASCII by default). */
char *pattern = g_strdup_printf("%%%s%%", query);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM history "
"WHERE url LIKE ? OR title LIKE ? "
"ORDER BY visit_count DESC, visited_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_free(pattern);
return -1;
}
sqlite3_bind_text(stmt, 1, pattern, -1, g_free);
sqlite3_bind_text(stmt, 2, pattern, -1, NULL); /* same pattern, freed once */
sqlite3_bind_int(stmt, 3, limit);
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Re-bind (sqlite3_reset keeps bindings, but reset clears them in some
* versions — re-bind to be safe). */
char *pattern2 = g_strdup_printf("%%%s%%", query);
sqlite3_bind_text(stmt, 1, pattern2, -1, g_free);
sqlite3_bind_text(stmt, 2, pattern2, -1, NULL);
sqlite3_bind_int(stmt, 3, limit);
/* Allocate arrays. */
*urls_out = g_new0(char *, count);
if (titles_out) *titles_out = g_new0(char *, count);
if (count_out) *count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *url = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*urls_out)[i] = g_strdup(url ? url : "");
if (titles_out) {
(*titles_out)[i] = g_strdup(title ? title : "");
}
i++;
}
sqlite3_finalize(stmt);
if (count_out) *count_out = i;
return i;
}
/* ── Session ───────────────────────────────────────────────────────── */
int db_session_save(const char **urls, const char **titles, int count) {

View File

@@ -126,6 +126,22 @@ int db_history_get(char ***urls_out, char ***titles_out,
*/
int db_history_clear(void);
/*
* Search history entries by URL or title substring.
* query — substring to search for (case-insensitive)
* limit — max number of entries (0 = default 10)
*
* Results are ranked by visit_count DESC, then visited_at DESC — so
* frequently-visited and recently-visited sites appear first.
*
* Fills urls_out and titles_out with parallel arrays of newly allocated
* strings. Caller must free each string and the arrays themselves.
* Returns the number of entries, or -1 on error.
*/
int db_history_search(const char *query,
char ***urls_out, char ***titles_out,
int *count_out, int limit);
/* ── Session ───────────────────────────────────────────────────────── */
/*

View File

@@ -39,6 +39,8 @@
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
#include "shortcuts.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include "session.h"
#include "agent_server.h"
@@ -65,6 +67,7 @@ typedef struct {
static app_state_t g_state = {0};
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
@@ -78,6 +81,10 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
g_state.pubkey_hex[64] = '\0';
}
g_logged_in = TRUE;
/* Update modules that hold a signer reference. */
settings_sync_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
}
void app_clear_signer(void) {
@@ -89,6 +96,8 @@ void app_clear_signer(void) {
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
}
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
@@ -225,30 +234,32 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
}
}
/* ---- Keyboard shortcuts --------------------------------------------- */
/* ---- Keyboard shortcuts --------------------------------------------- *
* All browser-level shortcuts are configurable via the shortcuts module.
* The on_key_press handler looks up the action for the pressed key
* combination and dispatches it. Bindings are set on the
* sovereign://settings page (key-capture UI) and persisted to SQLite.
*/
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
gpointer data) {
(void)widget;
(void)data;
const browser_settings_t *s = settings_get();
guint mods = event->state & gtk_accelerator_get_default_mod_mask();
int action = shortcuts_lookup(event);
if (action < 0) return FALSE;
/* Ctrl+T — new tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_t) {
switch ((shortcut_action_t)action) {
case SHORTCUT_NEW_TAB:
tab_manager_new_tab(NULL);
return TRUE;
}
/* Ctrl+W — close active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_w) {
case SHORTCUT_CLOSE_TAB:
tab_manager_close_active();
return TRUE;
}
/* Ctrl+L — focus URL bar of active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_l) {
case SHORTCUT_FOCUS_URL: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->url_entry) {
gtk_widget_grab_focus(tab->url_entry);
@@ -257,35 +268,78 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
return TRUE;
}
/* Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs. */
if (s->ctrl_tab_switch && (mods & GDK_CONTROL_MASK)) {
if (event->keyval == GDK_KEY_Tab) {
if (mods & GDK_SHIFT_MASK) {
tab_manager_prev();
} else {
tab_manager_next();
}
return TRUE;
}
if (event->keyval == GDK_KEY_ISO_Left_Tab) {
tab_manager_prev();
return TRUE;
}
}
/* Ctrl+PageDown — next tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Down) {
case SHORTCUT_NEXT_TAB:
tab_manager_next();
return TRUE;
}
/* Ctrl+PageUp — previous tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Up) {
case SHORTCUT_PREV_TAB:
tab_manager_prev();
return TRUE;
case SHORTCUT_NEXT_TAB_PAGEDOWN:
tab_manager_next();
return TRUE;
case SHORTCUT_PREV_TAB_PAGEUP:
tab_manager_prev();
return TRUE;
case SHORTCUT_RELOAD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview)
webkit_web_view_reload(tab->webview);
return TRUE;
}
return FALSE;
case SHORTCUT_FORCE_RELOAD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview)
webkit_web_view_reload_bypass_cache(tab->webview);
return TRUE;
}
case SHORTCUT_GO_BACK: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview))
webkit_web_view_go_back(tab->webview);
return TRUE;
}
case SHORTCUT_GO_FORWARD: {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview))
webkit_web_view_go_forward(tab->webview);
return TRUE;
}
case SHORTCUT_FIND:
/* Phase 2: wire WebKitFindController + find bar.
* For now, log so the user knows the binding fired. */
g_print("[shortcut] Find in page (not yet implemented)\n");
return TRUE;
case SHORTCUT_OPEN_SETTINGS:
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
case SHORTCUT_TOGGLE_FULLSCREEN: {
if (g_is_fullscreen) {
gtk_window_unfullscreen(g_window);
g_is_fullscreen = FALSE;
} else {
gtk_window_fullscreen(g_window);
g_is_fullscreen = TRUE;
}
return TRUE;
}
default:
return FALSE;
}
}
/* ---- Agent login callback ------------------------------------------ *
@@ -406,6 +460,11 @@ int main(int argc, char **argv) {
/* Load settings from the database (key_value table). */
settings_load();
/* Load keyboard shortcut bindings from the database. Must come after
* settings_load() because shortcuts_lookup() checks the master
* ctrl_tab_switch toggle. */
shortcuts_load();
/* History is queried from the SQLite database on demand — no
* separate load step needed. */
@@ -543,6 +602,17 @@ int main(int argc, char **argv) {
bookmarks_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the NIP-78 settings sync module. In no-login/read-only
* mode, the signer is NULL so settings are local-only (not synced). */
settings_sync_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Set the user's avatar on the tab bar from their kind 0 profile.
* If the profile hasn't been fetched yet (relay fetch is still
* running), this shows the default icon. The avatar will be updated
* when the relay fetch completes and the kind 0 event is stored. */
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */

View File

@@ -17,10 +17,13 @@
#include "nostr_core/nip021.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include "settings.h"
#include "shortcuts.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include "qr.h"
#include "db.h"
#include "bookmarks.h"
#include "search.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
@@ -409,44 +412,60 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
case GTK_POS_RIGHT: pos_str = "right"; break;
}
/* Build a nostr:npub1... URI for the QR code in the Identity section.
* Convert the hex pubkey to 32 bytes, then use nip021 to build the URI.
* If no identity is loaded, skip the QR code. */
char npub_uri[160] = "";
char qr_img_html[512] = "";
if (g_bridge.pubkey_hex[0] != '\0') {
unsigned char pubkey_bytes[32] = {0};
int valid = 1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
valid = 0;
break;
}
pubkey_bytes[i] = (unsigned char)byte;
}
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
sizeof(npub_uri)) == 0) {
/* URL-encode the nostr: URI for use in an <img src> query. */
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
snprintf(qr_img_html, sizeof(qr_img_html),
" <div style='text-align:center; margin: 12px 0;'>"
"<img src='sovereign://qr?text=%s&box_size=6&border=4' "
"alt='npub QR code' style='image-rendering: pixelated; "
"border: 4px solid #fff; border-radius: 4px;'>"
"<div class='info' style='margin-top: 6px;'>"
"Scan with a Nostr client to load this identity</div>"
"</div>\n",
enc);
g_free(enc);
}
}
/* HTML-escape the bootstrap relays for the <textarea>. Newlines are
* preserved (they're valid in textarea content), but <, >, & are
* escaped to prevent breaking the HTML. */
char *relays_escaped = g_markup_escape_text(bs->bootstrap_relays, -1);
/* Build the search engine <option> elements. */
GString *search_engine_options = g_string_new(NULL);
{
const search_engine_t *engines = search_engines_get();
for (int i = 0; engines[i].id != NULL; i++) {
gboolean selected = (strcmp(engines[i].id, bs->search_engine) == 0);
g_string_append_printf(search_engine_options,
" <option value='%s'%s>%s</option>\n",
engines[i].id,
selected ? " selected" : "",
engines[i].name);
}
}
/* Build the keyboard shortcuts section HTML. One row per action. */
GString *shortcuts_html = g_string_new(NULL);
g_string_append(shortcuts_html,
"<h2>Keyboard Shortcuts</h2>\n"
"<p class='note'>Click <b>Change</b> then press the key combination "
"you want. Press <b>Escape</b> to cancel.</p>\n");
for (int i = 0; i < SHORTCUT_COUNT; i++) {
const shortcut_meta_t *m = shortcuts_meta((shortcut_action_t)i);
const char *accel = shortcuts_get((shortcut_action_t)i);
char *label_esc = g_markup_escape_text(m->label, -1);
char *desc_esc = g_markup_escape_text(m->desc, -1);
char *accel_esc = g_markup_escape_text(accel ? accel : "", -1);
g_string_append_printf(shortcuts_html,
"<div class='field shortcut-row' data-action='%s' data-accel='%s'>\n"
" <div><div class='setting-name'>%s</div>\n"
" <div class='setting-desc'>%s</div></div>\n"
" <div><span class='shortcut-display' id='sc_%s'>%s</span>\n"
" <button class='save-btn sc-change' data-action='%s'>Change</button>\n"
" <button class='save-btn sc-reset' data-action='%s'>Reset</button></div>\n"
"</div>\n",
m->id, accel ? accel : "",
label_esc, desc_esc,
m->id, accel_esc,
m->id, m->id);
g_free(label_esc);
g_free(desc_esc);
g_free(accel_esc);
}
g_string_append(shortcuts_html,
"<div class='field'>"
"<div><div class='setting-name'>Reset all shortcuts</div>"
"<div class='setting-desc'>Restore all bindings to defaults</div></div>"
"<div><button class='save-btn' id='sc-reset-all'>Reset All</button></div>"
"</div>\n");
/* Build the HTML page. */
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
@@ -490,16 +509,20 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" .note { color: #888; font-size: 12px; margin: 10px 0 20px 0; }\n"
" .info { color: #888; font-size: 12px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
" .shortcut-display { display: inline-block; min-width: 120px;\n"
" padding: 4px 10px; background: #2a2a2a; border: 1px solid #444;\n"
" border-radius: 4px; font-family: monospace; font-size: 13px;\n"
" text-align: center; }\n"
" .shortcut-display.capturing { border-color: #ff4444;\n"
" background: #3a1a1a; animation: pulse 1s infinite; }\n"
" @keyframes pulse { 0%%,100%%{opacity:1} 50%%{opacity:0.5} }\n"
" .shortcut-row.duplicate .shortcut-display { border-color: #ff4444;\n"
" background: #3a1a1a; }\n"
" .sc-change, .sc-reset { min-width: 70px; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Settings</h1>\n"
"\n"
"<h2>Identity</h2>\n"
" <div class='info-row'><span>Pubkey</span><span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>Method</span><span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span><span class='info'>%s</span></div>\n"
"%s"
"\n"
"<h2>Tabs</h2>\n"
"\n"
"<div class='setting'>\n"
@@ -558,6 +581,8 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" <button class='save-btn' onclick=\"save('max_tabs')\">Save</button></div>\n"
"</div>\n"
"\n"
"%s" /* keyboard shortcuts section */
"\n"
"<h2>Agent Server</h2>\n"
"\n"
"<div class='setting'>\n"
@@ -597,6 +622,17 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" <button class='save-btn' onclick=\"save('bootstrap_relays')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Search</h2>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Search engine</div>\n"
" <div class='setting-desc'>Engine used when typing a query in the URL bar</div></div>\n"
" <div><select id='search_engine'>\n"
"%s"
" </select>\n"
" <button class='save-btn' onclick=\"save('search_engine')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Security</h2>\n"
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
"at a different layer (Nostr keys + FIPS mesh), so the browser's own security\n"
@@ -669,12 +705,166 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"\n"
" /* ── Keyboard shortcut capture ─────────────────────────── */\n"
" var capturingAction = null;\n"
" var capturingDisplay = null;\n"
"\n"
" /* Map JS event.key to GDK key name for the ~20 keys we care about. */\n"
" function jsKeyToGdk(e) {\n"
" var k = e.key;\n"
" if (k.length === 1) {\n"
" var c = k.toLowerCase();\n"
" if (c >= 'a' && c <= 'z') return c;\n"
" if (c >= '0' && c <= '9') return c;\n"
" if (k === ',') return 'comma';\n"
" if (k === ' ') return 'space';\n"
" if (k === '.') return 'period';\n"
" if (k === '/') return 'slash';\n"
" if (k === '-') return 'minus';\n"
" if (k === '=') return 'equal';\n"
" }\n"
" var special = {\n"
" 'Tab':'Tab','Enter':'Return','Escape':'Escape',\n"
" 'Backspace':'BackSpace','Delete':'Delete',\n"
" 'Home':'Home','End':'End',\n"
" 'PageUp':'Page_Up','PageDown':'Page_Down',\n"
" 'ArrowLeft':'Left','ArrowRight':'Right',\n"
" 'ArrowUp':'Up','ArrowDown':'Down',\n"
" 'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6',\n"
" 'F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12'\n"
" };\n"
" if (special[k]) return special[k];\n"
" return null;\n"
" }\n"
"\n"
" function buildAccel(e) {\n"
" var parts = [];\n"
" if (e.ctrlKey) parts.push('<Control>');\n"
" if (e.altKey) parts.push('<Alt>');\n"
" if (e.shiftKey) parts.push('<Shift>');\n"
" if (e.metaKey) parts.push('<Super>');\n"
" var gdkKey = jsKeyToGdk(e);\n"
" if (!gdkKey) return null;\n"
" parts.push(gdkKey);\n"
" return parts.join('');\n"
" }\n"
"\n"
" function displayAccel(accel) {\n"
" return accel.replace(/<Control>/g,'Ctrl+')\n"
" .replace(/<Alt>/g,'Alt+')\n"
" .replace(/<Shift>/g,'Shift+')\n"
" .replace(/<Super>/g,'Super+');\n"
" }\n"
"\n"
" function checkDuplicates() {\n"
" var rows = document.querySelectorAll('.shortcut-row');\n"
" var seen = {};\n"
" rows.forEach(function(r) {\n"
" r.classList.remove('duplicate');\n"
" var a = r.getAttribute('data-accel');\n"
" if (a) {\n"
" if (seen[a]) seen[a].push(r);\n"
" else seen[a] = [r];\n"
" }\n"
" });\n"
" Object.keys(seen).forEach(function(a) {\n"
" if (seen[a].length > 1) {\n"
" seen[a].forEach(function(r) { r.classList.add('duplicate'); });\n"
" }\n"
" });\n"
" }\n"
"\n"
" function commitShortcut(action, accel) {\n"
" fetch('sovereign://settings/set?key=shortcut.' + action\n"
" + '&value=' + encodeURIComponent(accel))\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" if (d.error) {\n"
" showStatus('err', d.message);\n"
" } else {\n"
" showStatus('ok', action + ' = ' + displayAccel(accel));\n"
" setTimeout(() => location.reload(), 400);\n"
" }\n"
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"\n"
" function resetShortcut(action) {\n"
" fetch('sovereign://settings/set?key=shortcut.reset&action=' + action)\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" if (d.error) { showStatus('err', d.message); }\n"
" else { showStatus('ok', action + ' reset to default');\n"
" setTimeout(() => location.reload(), 400); }\n"
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"\n"
" function resetAllShortcuts() {\n"
" fetch('sovereign://settings/set?key=shortcut.reset_all')\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" if (d.error) { showStatus('err', d.message); }\n"
" else { showStatus('ok', 'All shortcuts reset to defaults');\n"
" setTimeout(() => location.reload(), 400); }\n"
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if (!capturingAction) return;\n"
" e.preventDefault();\n"
" e.stopPropagation();\n"
" if (e.key === 'Escape') {\n"
" capturingDisplay.classList.remove('capturing');\n"
" capturingDisplay.textContent =\n"
" capturingDisplay.getAttribute('data-prev') || '';\n"
" capturingAction = null;\n"
" capturingDisplay = null;\n"
" showStatus('ok', 'Cancelled');\n"
" return;\n"
" }\n"
" /* Ignore bare modifier presses — wait for a real key. */\n"
" var bareMod = ['Control','Shift','Alt','Meta'].indexOf(e.key) >= 0;\n"
" if (bareMod) return;\n"
" var accel = buildAccel(e);\n"
" if (!accel) return;\n"
" capturingDisplay.classList.remove('capturing');\n"
" capturingDisplay.textContent = displayAccel(accel);\n"
" var action = capturingAction;\n"
" capturingAction = null;\n"
" capturingDisplay = null;\n"
" commitShortcut(action, accel);\n"
" }, true);\n"
"\n"
" document.addEventListener('DOMContentLoaded', function() {\n"
" document.querySelectorAll('.sc-change').forEach(function(btn) {\n"
" btn.addEventListener('click', function() {\n"
" var action = btn.getAttribute('data-action');\n"
" var disp = document.getElementById('sc_' + action);\n"
" if (!disp) return;\n"
" if (capturingDisplay) {\n"
" capturingDisplay.classList.remove('capturing');\n"
" }\n"
" capturingAction = action;\n"
" capturingDisplay = disp;\n"
" disp.setAttribute('data-prev', disp.textContent);\n"
" disp.classList.add('capturing');\n"
" disp.textContent = 'Press keys…';\n"
" });\n"
" });\n"
" document.querySelectorAll('.sc-reset').forEach(function(btn) {\n"
" btn.addEventListener('click', function() {\n"
" resetShortcut(btn.getAttribute('data-action'));\n"
" });\n"
" });\n"
" var ra = document.getElementById('sc-reset-all');\n"
" if (ra) ra.addEventListener('click', resetAllShortcuts);\n"
" checkDuplicates();\n"
" });\n"
"</script>\n"
"</body></html>\n",
g_bridge.pubkey_hex[0] ? g_bridge.pubkey_hex : "(none)",
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
qr_img_html,
bs->restore_session ? "on" : "off",
bs->new_tab_url,
strcmp(pos_str, "top") == 0 ? " selected" : "",
@@ -686,11 +876,13 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
bs->ctrl_tab_switch ? "on" : "off",
bs->tab_drag_reorder ? "on" : "off",
bs->max_tabs,
shortcuts_html->str,
bs->agent_server_enabled ? "on" : "off",
bs->agent_server_port,
bs->agent_allowed_origins,
bs->agent_login_timeout_ms,
relays_escaped,
search_engine_options->str,
dev_extras ? "on" : "off",
file_access ? "on" : "off",
universal_access ? "on" : "off"
@@ -699,6 +891,8 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
respond_html(request, html);
g_free(html);
g_free(relays_escaped);
g_string_free(search_engine_options, TRUE);
g_string_free(shortcuts_html, TRUE);
}
/* Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y). */
@@ -771,6 +965,9 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
g_print("[settings] %s: %s\n", feature, new_state ? "ON" : "OFF");
/* Trigger debounced NIP-78 sync for syncable toggled settings. */
settings_sync_publish();
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", feature);
cJSON_AddBoolToObject(result, "value", new_state);
@@ -786,6 +983,51 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
/* ── Key/value path: ?key=X&value=Y ───────────────────────────── */
char *key = query_param(query, "key");
char *value = query_param(query, "value");
/* ── Shortcut reset pseudo-keys (no value needed) ──────────────── */
if (key != NULL && strcmp(key, "shortcut.reset_all") == 0) {
shortcuts_reset_all();
settings_sync_publish();
g_print("[settings] shortcut.reset_all\n");
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", "shortcut.reset_all");
cJSON_AddStringToObject(result, "value", "defaults");
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(key);
g_free(value);
return;
}
if (key != NULL && strcmp(key, "shortcut.reset") == 0) {
char *action_id = query_param(query, "action");
if (action_id == NULL) {
respond_error_json(request, -1, "Missing action parameter");
g_free(key); g_free(value); g_free(action_id);
return;
}
int action = shortcuts_action_from_id(action_id);
if (action < 0) {
respond_error_json(request, -1, "Unknown shortcut action");
g_free(key); g_free(value); g_free(action_id);
return;
}
shortcuts_reset((shortcut_action_t)action);
settings_sync_publish();
const char *dflt = shortcuts_get((shortcut_action_t)action);
g_print("[settings] shortcut.reset %s -> %s\n", action_id, dflt);
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", "shortcut.reset");
cJSON_AddStringToObject(result, "value", dflt);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(key); g_free(value); g_free(action_id);
return;
}
if (key == NULL || value == NULL) {
respond_error_json(request, -1, "Missing key or value parameter");
g_free(key);
@@ -841,6 +1083,33 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
snprintf(bs->bootstrap_relays,
sizeof(bs->bootstrap_relays), "%s", value);
settings_save();
} else if (strcmp(key, "search_engine") == 0) {
/* Validate that the engine id is one of the built-in engines. */
if (search_engine_by_id(value) == NULL) {
respond_error_json(request, -1, "Unknown search engine");
g_free(key); g_free(value);
return;
}
snprintf(bs->search_engine,
sizeof(bs->search_engine), "%s", value);
settings_save();
} else if (strncmp(key, "shortcut.", 9) == 0) {
/* Keyboard shortcut binding: key="shortcut.<action_id>"
* value="<GTK accelerator string>". */
const char *action_id = key + 9;
int action = shortcuts_action_from_id(action_id);
if (action < 0) {
respond_error_json(request, -1, "Unknown shortcut action");
g_free(key); g_free(value);
return;
}
if (shortcuts_set((shortcut_action_t)action, value) != 0) {
respond_error_json(request, -1, "Invalid key combination");
g_free(key); g_free(value);
return;
}
/* Trigger debounced NIP-78 sync. */
settings_sync_publish();
} else {
respond_error_json(request, -1, "Unknown key");
g_free(key); g_free(value);
@@ -849,6 +1118,11 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
g_print("[settings] %s = %s\n", key, value);
/* Trigger debounced NIP-78 sync for syncable settings. The
* settings_sync module only serializes whitelisted keys, so calling
* it for non-syncable keys is harmless (they won't be included). */
settings_sync_publish();
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", key);
cJSON_AddStringToObject(result, "value", value);

View File

@@ -14,6 +14,8 @@
#include "db.h"
#include "settings.h"
#include "bookmarks.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include <string.h>
#include <stdlib.h>
@@ -28,6 +30,9 @@
/* Relay query timeout in seconds. */
#define RELAY_TIMEOUT_SECONDS 15
/* Forward declaration — defined after relay_fetch_bootstrap. */
static gboolean avatar_refresh_idle(gpointer data);
/* ── Public API ────────────────────────────────────────────────────── */
int relay_fetch_bootstrap(const char *pubkey_hex,
@@ -42,10 +47,10 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
return -1;
}
g_print("[relay] Fetching kind 0/3/10002 for %s from %d relay(s)...\n",
g_print("[relay] Fetching kind 0/3/10002/30003/30078 for %s from %d relay(s)...\n",
pubkey_hex, relay_count);
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002]} */
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
@@ -56,6 +61,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* bookmark sets */
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30078)); /* NIP-78 app data */
cJSON_AddItemToObject(filter, "kinds", kinds);
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
@@ -79,7 +85,9 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
}
/* Store each event in the database. Kind 30003 (bookmark sets) are
* also decrypted and loaded into the in-memory bookmarks list. */
* also decrypted and loaded into the in-memory bookmarks list.
* Kind 30078 (NIP-78 app data) is stored and merged into local
* settings if it's our sovereign_browser settings event. */
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
@@ -93,6 +101,14 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
if (bookmarks_store_and_load_event(results[i]) == 0) {
stored++;
}
} else if (kind == 30078) {
/* Store in SQLite first, then try to merge into local settings.
* settings_sync_merge_from_nostr checks the d-tag and only
* merges if it's our "sovereign_browser" event. */
if (db_store_event(results[i]) == 0) {
stored++;
}
settings_sync_merge_from_nostr(results[i]);
} else {
if (db_store_event(results[i]) == 0) {
stored++;
@@ -107,9 +123,23 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
g_print("[relay] Fetched %d event(s), stored %d in database\n",
result_count, stored);
/* Refresh the user's avatar now that the kind 0 profile may be
* available in the database. This must run on the main thread
* because it touches GTK widgets. */
g_idle_add(avatar_refresh_idle, g_strdup(pubkey_hex));
return stored;
}
/* Idle callback wrapper to refresh the avatar on the main thread. */
static gboolean avatar_refresh_idle(gpointer data) {
char *pubkey = (char *)data;
tab_manager_set_avatar(pubkey);
g_free(pubkey);
return G_SOURCE_REMOVE;
}
/* ── Background thread ─────────────────────────────────────────────── */
gpointer relay_fetch_thread(gpointer data) {

367
src/search.c Normal file
View File

@@ -0,0 +1,367 @@
/*
* search.c — search engine integration for sovereign_browser
*
* Implements search engine configuration, URL building, async autocomplete
* suggestion fetching via libsoup, and a URL-vs-query heuristic.
*
* The active engine is stored in settings ("search_engine" key) and
* synced via NIP-78 (settings_sync).
*/
#include "search.h"
#include "settings.h"
#include "db.h"
#include <libsoup/soup.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* Vendored cJSON for parsing suggestion API responses. */
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Built-in search engines ────────────────────────────────────────── */
static const search_engine_t g_engines[] = {
{
"duckduckgo", "DuckDuckGo",
"https://duckduckgo.com/?q=%s",
"https://duckduckgo.com/ac/?q=%s&type=list"
},
{
"google", "Google",
"https://www.google.com/search?q=%s",
"https://suggestqueries.google.com/complete/search?client=firefox&q=%s"
},
{
"brave", "Brave Search",
"https://search.brave.com/search?q=%s",
"https://search.brave.com/api/suggest?q=%s"
},
{
"startpage", "Startpage",
"https://www.startpage.com/sp/search?query=%s",
NULL /* no public autocomplete API */
},
{
"searx", "Searx",
"https://searx.be/search?q=%s",
NULL
},
{ NULL, NULL, NULL, NULL } /* sentinel */
};
const search_engine_t *search_engines_get(void) {
return g_engines;
}
int search_engines_count(void) {
int count = 0;
while (g_engines[count].id != NULL) count++;
return count;
}
const search_engine_t *search_engine_by_id(const char *id) {
if (id == NULL || id[0] == '\0') return NULL;
for (int i = 0; g_engines[i].id != NULL; i++) {
if (strcmp(g_engines[i].id, id) == 0) {
return &g_engines[i];
}
}
return NULL;
}
const search_engine_t *search_engine_get_active(void) {
const browser_settings_t *s = settings_get();
const search_engine_t *engine = search_engine_by_id(s->search_engine);
if (engine == NULL) {
/* Fall back to DuckDuckGo if the configured engine is unknown. */
engine = &g_engines[0];
}
return engine;
}
int search_engine_set_active(const char *id) {
if (search_engine_by_id(id) == NULL) {
return -1;
}
browser_settings_t *s = settings_get_mutable();
snprintf(s->search_engine, sizeof(s->search_engine), "%s", id);
settings_save();
return 0;
}
/* ── URL building ──────────────────────────────────────────────────── */
char *search_build_search_url_for(const search_engine_t *engine,
const char *query) {
if (engine == NULL || query == NULL) return NULL;
/* URL-encode the query for use in a URL query parameter. */
char *encoded = g_uri_escape_string(query, NULL, FALSE);
if (encoded == NULL) return NULL;
char *url = g_strdup_printf(engine->search_url, encoded);
g_free(encoded);
return url;
}
char *search_build_search_url(const char *query) {
return search_build_search_url_for(search_engine_get_active(), query);
}
/* ── URL heuristic ─────────────────────────────────────────────────── */
gboolean search_is_url(const char *input) {
if (input == NULL || input[0] == '\0') return FALSE;
/* Has a scheme (e.g. "https://", "http://", "ftp://"). */
if (strstr(input, "://") != NULL) return TRUE;
/* Internal about: pages. */
if (strncmp(input, "about:", 6) == 0) return TRUE;
/* sovereign:// internal pages. */
if (strncmp(input, "sovereign://", 12) == 0) return TRUE;
/* If it contains spaces, it's almost certainly a search query. */
if (strchr(input, ' ') != NULL) return FALSE;
/* "localhost" or "localhost:port". */
if (strncmp(input, "localhost", 9) == 0) return TRUE;
/* Check for a dot — looks like a domain name.
* But require at least one char before and after the dot. */
const char *dot = strchr(input, '.');
if (dot != NULL && dot > input && dot[1] != '\0') {
return TRUE;
}
/* Check for IPv4 address (4 numbers separated by dots). */
{
int a, b, c, d;
if (sscanf(input, "%d.%d.%d.%d", &a, &b, &c, &d) == 4) {
if (a >= 0 && a <= 255 && b >= 0 && b <= 255 &&
c >= 0 && c <= 255 && d >= 0 && d <= 255) {
return TRUE;
}
}
}
return FALSE;
}
/* ── Async suggestion fetch ────────────────────────────────────────── */
/* A pending suggestion request. */
typedef struct {
guint id; /* unique request ID */
SoupSession *session; /* libsoup session */
SoupMessage *msg; /* in-flight HTTP request */
search_suggest_callback callback;
gpointer user_data;
GCancellable *cancellable;
} suggest_request_t;
/* Global counter for request IDs. */
static guint g_next_request_id = 1;
/* Active requests, keyed by ID. We keep a simple list since there's
* typically only one active at a time (the latest keystroke). */
static GList *g_active_requests = NULL;
/*
* Parse a suggestion API response.
*
* DuckDuckGo returns: ["query", ["sugg1", "sugg2", ...]]
* Google (client=firefox) returns: ["query", ["sugg1", "sugg2", ...]]
* Brave returns: ["sugg1", "sugg2", ...] (plain array of strings)
*
* Returns a NULL-terminated array of newly allocated strings, or NULL.
*/
static char **parse_suggestions(const char *body, gsize body_len) {
if (body == NULL || body_len == 0) return NULL;
cJSON *root = cJSON_ParseWithLength(body, body_len);
if (root == NULL) return NULL;
char **results = g_new0(char *, SEARCH_SUGGESTION_MAX + 1);
int count = 0;
if (cJSON_IsArray(root)) {
int arr_size = cJSON_GetArraySize(root);
/* DuckDuckGo / Google format: first element is the query string,
* second element is an array of suggestions. */
if (arr_size >= 2 && cJSON_IsString(cJSON_GetArrayItem(root, 0))) {
cJSON *suggestions = cJSON_GetArrayItem(root, 1);
if (cJSON_IsArray(suggestions)) {
int sugg_size = cJSON_GetArraySize(suggestions);
for (int i = 0; i < sugg_size && count < SEARCH_SUGGESTION_MAX; i++) {
cJSON *item = cJSON_GetArrayItem(suggestions, i);
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
results[count++] = g_strdup(item->valuestring);
}
}
}
} else {
/* Brave format: plain array of strings. */
for (int i = 0; i < arr_size && count < SEARCH_SUGGESTION_MAX; i++) {
cJSON *item = cJSON_GetArrayItem(root, i);
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
results[count++] = g_strdup(item->valuestring);
}
}
}
}
cJSON_Delete(root);
if (count == 0) {
g_free(results);
return NULL;
}
return results;
}
/*
* Idle callback to deliver results on the main thread.
*/
typedef struct {
search_suggest_callback callback;
char **suggestions;
gpointer user_data;
} idle_delivery_t;
static gboolean deliver_suggestions_idle(gpointer data) {
idle_delivery_t *delivery = (idle_delivery_t *)data;
delivery->callback(delivery->suggestions, delivery->user_data);
/* Free the suggestions array after the callback has consumed them. */
if (delivery->suggestions) {
for (int i = 0; delivery->suggestions[i] != NULL; i++) {
g_free(delivery->suggestions[i]);
}
g_free(delivery->suggestions);
}
g_free(delivery);
return G_SOURCE_REMOVE;
}
/*
* GAsyncReadyCallback for soup_session_send_and_read_async.
* Called when the full response body has been downloaded.
*/
static void on_suggest_async_ready(GObject *source_object, GAsyncResult *res,
gpointer user_data) {
suggest_request_t *req = (suggest_request_t *)user_data;
SoupSession *session = SOUP_SESSION(source_object);
char **suggestions = NULL;
GBytes *bytes = soup_session_send_and_read_finish(session, res, NULL);
if (bytes != NULL) {
gsize size = 0;
const gchar *data = g_bytes_get_data(bytes, &size);
if (data && size > 0) {
suggestions = parse_suggestions(data, size);
}
g_bytes_unref(bytes);
}
/* Deliver on the main thread via an idle handler. We're already on
* the main thread (libsoup-3.0 async callbacks run on the thread
* that started the request), but the idle handler avoids re-entrancy
* issues with the entry completion widget. */
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
delivery->callback = req->callback;
delivery->suggestions = suggestions;
delivery->user_data = req->user_data;
g_idle_add(deliver_suggestions_idle, delivery);
/* Clean up. */
g_active_requests = g_list_remove(g_active_requests, req);
g_object_unref(req->msg);
if (req->cancellable) g_object_unref(req->cancellable);
g_object_unref(req->session);
g_free(req);
}
guint search_suggest_fetch_async(const char *query,
search_suggest_callback callback,
gpointer user_data) {
if (query == NULL || query[0] == '\0' || callback == NULL) return 0;
const search_engine_t *engine = search_engine_get_active();
if (engine->suggest_url == NULL) {
/* Engine has no suggestion API — deliver NULL immediately. */
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
delivery->callback = callback;
delivery->suggestions = NULL;
delivery->user_data = user_data;
g_idle_add(deliver_suggestions_idle, delivery);
return 0;
}
/* Build the suggestion URL. */
char *encoded = g_uri_escape_string(query, NULL, FALSE);
if (encoded == NULL) return 0;
char *url = g_strdup_printf(engine->suggest_url, encoded);
g_free(encoded);
if (url == NULL) return 0;
/* Create the request. */
suggest_request_t *req = g_new0(suggest_request_t, 1);
req->id = g_next_request_id++;
req->callback = callback;
req->user_data = user_data;
req->cancellable = g_cancellable_new();
/* Create a SoupSession per request. Suggestion requests are
* infrequent (one per keystroke with debouncing) and this avoids
* thread-safety concerns with a shared session. */
req->session = soup_session_new();
g_object_set(req->session, "timeout", 5, "idle-timeout", 5, NULL);
req->msg = soup_message_new("GET", url);
g_free(url);
if (req->msg == NULL) {
g_object_unref(req->cancellable);
g_object_unref(req->session);
g_free(req);
return 0;
}
/* Set a User-Agent so the APIs don't block us. */
soup_message_headers_replace(soup_message_get_request_headers(req->msg),
"User-Agent",
"sovereign_browser/1.0");
g_active_requests = g_list_prepend(g_active_requests, req);
/* Send the request asynchronously. on_suggest_async_ready is
* invoked when the full response body has been downloaded. */
soup_session_send_and_read_async(req->session, req->msg,
G_PRIORITY_DEFAULT,
req->cancellable,
on_suggest_async_ready,
req);
return req->id;
}
void search_suggest_cancel(guint request_id) {
if (request_id == 0) return;
GList *l = g_active_requests;
while (l != NULL) {
suggest_request_t *req = (suggest_request_t *)l->data;
if (req->id == request_id) {
g_cancellable_cancel(req->cancellable);
return; /* The async callback will handle cleanup. */
}
l = l->next;
}
}

137
src/search.h Normal file
View File

@@ -0,0 +1,137 @@
/*
* search.h — search engine integration for sovereign_browser
*
* Provides search engine configuration (DuckDuckGo default, with Google,
* Brave, Startpage, and Searx as alternatives), URL building from query
* strings, async autocomplete suggestion fetching, and a URL-vs-query
* heuristic for the URL bar.
*
* The active engine name is stored in settings ("search_engine" key) and
* synced across devices via NIP-78.
*/
#ifndef SEARCH_H
#define SEARCH_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ── Search engine definitions ──────────────────────────────────────── */
#define SEARCH_ENGINE_NAME_MAX 64
#define SEARCH_ENGINE_URL_MAX 512
#define SEARCH_SUGGESTION_MAX 128 /* max suggestions per request */
#define SEARCH_QUERY_MAX 1024
typedef struct {
const char *id; /* short identifier, e.g. "duckduckgo" */
const char *name; /* display name, e.g. "DuckDuckGo" */
const char *search_url; /* URL template with %s for the query */
const char *suggest_url; /* autocomplete API URL template, or NULL */
} search_engine_t;
/*
* Get the list of built-in search engines (NULL-terminated array).
* The array is static and valid for the lifetime of the program.
*/
const search_engine_t *search_engines_get(void);
/*
* Get the number of built-in search engines.
*/
int search_engines_count(void);
/*
* Look up a search engine by its id string.
* Returns the engine, or NULL if not found.
*/
const search_engine_t *search_engine_by_id(const char *id);
/*
* Get the currently active search engine (from settings).
* Always returns a valid engine — falls back to DuckDuckGo if the
* configured engine is unknown or unset.
*/
const search_engine_t *search_engine_get_active(void);
/*
* Set the active search engine by id. Persists to settings.
* Returns 0 on success, -1 if the id is unknown.
*/
int search_engine_set_active(const char *id);
/* ── URL building ──────────────────────────────────────────────────── */
/*
* Build a search results URL for the given query using the active engine.
* query — the search query string (raw, will be URL-encoded)
* Returns a newly allocated string (caller must g_free).
*/
char *search_build_search_url(const char *query);
/*
* Build a search results URL for the given query using a specific engine.
* Returns a newly allocated string (caller must g_free).
*/
char *search_build_search_url_for(const search_engine_t *engine,
const char *query);
/* ── URL heuristic ─────────────────────────────────────────────────── */
/*
* Determine whether the given input string looks like a URL rather than
* a search query.
*
* Returns TRUE if:
* - It contains "://" (has a scheme)
* - It starts with "about:" (internal pages)
* - It has no spaces AND contains a dot (looks like a domain)
* - It's a valid-looking IPv4 address
* - It's "localhost" or starts with "localhost:"
*/
gboolean search_is_url(const char *input);
/* ── Async suggestion fetch ────────────────────────────────────────── */
/*
* Callback invoked when autocomplete suggestions have been fetched.
* suggestions — NULL-terminated array of newly allocated strings,
* or NULL on error / no suggestions
* user_data — the pointer passed to search_suggest_fetch_async()
*/
typedef void (*search_suggest_callback)(char **suggestions,
gpointer user_data);
/*
* Asynchronously fetch autocomplete suggestions for the given query
* from the active search engine's suggestion API.
*
* If the active engine has no suggestion URL, the callback is called
* immediately with NULL. The callback is always invoked on the GTK
* main thread (via g_idle_add), so it's safe to touch GTK widgets.
*
* query — the partial search query
* callback — called with the results (or NULL on error)
* user_data — passed to the callback
*
* Returns a guint request ID (can be used with search_suggest_cancel()),
* or 0 if the request could not be started.
*/
guint search_suggest_fetch_async(const char *query,
search_suggest_callback callback,
gpointer user_data);
/*
* Cancel a pending suggestion request by its ID.
* Safe to call with 0 (no-op) or an already-completed request ID.
*/
void search_suggest_cancel(guint request_id);
#ifdef __cplusplus
}
#endif
#endif /* SEARCH_H */

View File

@@ -38,6 +38,8 @@ static void settings_set_defaults(browser_settings_t *s) {
s->agent_login_timeout_ms = SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT;
snprintf(s->bootstrap_relays, sizeof(s->bootstrap_relays), "%s",
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
SETTINGS_SEARCH_ENGINE_DEFAULT);
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
@@ -145,6 +147,9 @@ void settings_load(void) {
val = db_kv_get("bootstrap_relays");
if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val);
val = db_kv_get("search_engine");
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
}
void settings_save(void) {
@@ -173,6 +178,8 @@ void settings_save(void) {
/* Bootstrap relays are stored as-is (newlines are fine in SQLite). */
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
db_kv_set("search_engine", g_settings.search_engine);
}
const browser_settings_t *settings_get(void) {

View File

@@ -26,6 +26,8 @@ extern "C" {
"wss://laantungir.net/relay\n" \
"wss://relay.primal.net\n" \
"wss://relay.damus.io"
#define SETTINGS_SEARCH_ENGINE_MAX 64
#define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo"
typedef struct {
gboolean restore_session; /* restore open tabs on next launch */
@@ -41,6 +43,7 @@ typedef struct {
char agent_allowed_origins[SETTINGS_AGENT_ORIGINS_MAX]; /* "*" for any */
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
char search_engine[SETTINGS_SEARCH_ENGINE_MAX]; /* active search engine id */
} browser_settings_t;
/*

346
src/settings_sync.c Normal file
View File

@@ -0,0 +1,346 @@
/*
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs whitelisted, device-independent settings + keyboard shortcut
* bindings across all devices the user logs in on. Uses a single kind
* 30078 addressable event with d-tag "sovereign_browser". The content is
* NIP-44 encrypted (self-to-self) JSON.
*
* The publish path reuses the same pattern as bookmarks.c's
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
* SQLite → publish to bootstrap relays.
*
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
*/
#include "settings_sync.h"
#include "settings.h"
#include "shortcuts.h"
#include "db.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* Debounce: a timeout source id for the deferred publish. */
static guint g_publish_timeout_id = 0;
/* db_kv key for the last-synced timestamp. */
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
/* ── Whitelist of syncable settings ─────────────────────────────────── */
/* Keys from the settings struct that should be synced. Device-specific
* settings (agent port, allowed origins, session restore, security
* toggles) are intentionally excluded. */
static const char *g_sync_setting_keys[] = {
"new_tab_url",
"tab_bar_position",
"show_tab_close_buttons",
"middle_click_close",
"ctrl_tab_switch", /* master shortcuts toggle */
"tab_drag_reorder",
"max_tabs",
"bootstrap_relays",
"search_engine",
};
static const int g_sync_setting_count =
(int)(sizeof(g_sync_setting_keys) / sizeof(g_sync_setting_keys[0]));
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Encrypt a plaintext string with NIP-44 (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *encrypt_content(const char *plaintext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
char *ciphertext = NULL;
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
&ciphertext);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[settings_sync] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
return ciphertext;
}
/* Decrypt a NIP-44 ciphertext (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *decrypt_content(const char *ciphertext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
char *plaintext = NULL;
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
&plaintext);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[settings_sync] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* ── Serialization ──────────────────────────────────────────────────── */
/* Serialize all syncable settings + shortcuts to a JSON object:
* {"settings":{...}, "shortcuts":{...}}
* Returns a newly allocated cJSON object (caller must delete). */
static cJSON *serialize_payload(void) {
cJSON *root = cJSON_CreateObject();
/* Settings whitelist. */
cJSON *settings_obj = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
(void)s; /* read via db_kv_get to get string values uniformly */
for (int i = 0; i < g_sync_setting_count; i++) {
const char *key = g_sync_setting_keys[i];
const char *val = db_kv_get(key);
if (val) {
cJSON_AddStringToObject(settings_obj, key, val);
}
}
cJSON_AddItemToObject(root, "settings", settings_obj);
/* Shortcuts. */
cJSON *shortcuts_obj = shortcuts_serialize();
cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj);
return root;
}
/* ── Publish (debounced) ────────────────────────────────────────────── */
/* The actual publish operation (no debounce). */
static void do_publish(void) {
if (!g_have_signer) return;
/* Serialize. */
cJSON *payload = serialize_payload();
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[settings_sync] Failed to serialize payload\n");
return;
}
/* Encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
if (ciphertext == NULL) return;
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(SETTINGS_SYNC_D_TAG));
cJSON_AddItemToArray(tags, d_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
SETTINGS_SYNC_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[settings_sync] Failed to create/sign event\n");
cJSON_Delete(tags);
return;
}
/* Store in SQLite. */
db_store_event(event);
/* Record the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", (long)time(NULL));
db_kv_set(SYNC_TS_KEY, ts_buf);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[settings_sync] Published kind %d to %d/%d relays\n",
SETTINGS_SYNC_KIND, success_count, relay_count);
} else {
g_print("[settings_sync] No relays configured, event stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
}
/* GLib timeout callback for the debounced publish. */
static gboolean publish_timeout_cb(gpointer data) {
(void)data;
g_publish_timeout_id = 0;
do_publish();
return G_SOURCE_REMOVE;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void settings_sync_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
settings_sync_init(signer, pubkey_hex);
}
void settings_sync_publish(void) {
if (!g_have_signer) return;
/* Cancel any pending publish and schedule a new one in 500ms. */
if (g_publish_timeout_id > 0) {
g_source_remove(g_publish_timeout_id);
}
g_publish_timeout_id = g_timeout_add(500, publish_timeout_cb, NULL);
}
int settings_sync_merge_from_nostr(const void *event_cjson) {
const cJSON *event = (const cJSON *)event_cjson;
if (event == NULL) return -1;
if (!g_have_signer) return -1;
/* Verify the kind. */
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!cJSON_IsNumber(kind) || (long)kind->valuedouble != SETTINGS_SYNC_KIND) {
return -1;
}
/* Verify the d-tag is "sovereign_browser". */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return -1;
int is_ours = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) &&
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
is_ours = 1;
break;
}
}
if (!is_ours) return -1;
/* Get the event's created_at. */
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!cJSON_IsNumber(created_at)) return -1;
long event_ts = (long)created_at->valuedouble;
/* Compare to our last-synced timestamp. */
const char *ts_str = db_kv_get(SYNC_TS_KEY);
long local_ts = 0;
if (ts_str) local_ts = atol(ts_str);
if (event_ts <= local_ts) {
g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), "
"skipping merge\n", event_ts, local_ts);
return 0;
}
/* Decrypt the content. */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return -1;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return -1;
/* Parse the JSON payload. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL || !cJSON_IsObject(payload)) {
g_printerr("[settings_sync] Failed to parse decrypted payload\n");
if (payload) cJSON_Delete(payload);
return -1;
}
/* Merge settings. */
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(settings_obj)) {
cJSON *item;
cJSON_ArrayForEach(item, settings_obj) {
if (!cJSON_IsString(item)) continue;
/* Only merge whitelisted keys. */
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
}
/* Update the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
db_kv_set(SYNC_TS_KEY, ts_buf);
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
event_ts);
cJSON_Delete(payload);
return 0;
}

78
src/settings_sync.h Normal file
View File

@@ -0,0 +1,78 @@
/*
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs user-configurable, device-independent settings across all devices
* the user logs in on. Uses a single kind 30078 addressable event with
* d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self)
* JSON containing whitelisted settings + all keyboard shortcut bindings.
*
* Device-specific settings (agent port, allowed origins, session restore,
* security toggles) are NOT synced — they stay local only.
*
* See plans/keyboard-shortcuts.md for the full design.
*/
#ifndef SETTINGS_SYNC_H
#define SETTINGS_SYNC_H
#include <glib.h>
/* nostr_signer_t is needed for the init function */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* The d-tag identifier used for our kind 30078 event. */
#define SETTINGS_SYNC_D_TAG "sovereign_browser"
/* The Nostr kind for arbitrary custom app data (NIP-78). */
#define SETTINGS_SYNC_KIND 30078
/*
* Initialize the settings sync module. Stores the signer + pubkey
* references for later publish/merge operations.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
*
* Call after login and after shortcuts_load() / settings_load().
*/
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex);
/*
* Serialize all syncable settings + shortcuts, NIP-44 encrypt to self,
* build and sign a kind 30078 event, publish to bootstrap relays, and
* store in SQLite. Debounced internally (500ms) so rapid edits coalesce
* into a single publish.
*
* Safe to call from the main thread. No-op if no signer is available
* (read-only / no-login mode).
*/
void settings_sync_publish(void);
/*
* Decrypt and merge settings from a fetched kind 30078 event.
* Called by the relay fetch thread after login. Compares the event's
* created_at to the last-synced timestamp; if the Nostr event is newer,
* overwrites local db_kv values and reloads in-memory bindings.
*
* event — a cJSON object representing a kind 30078 event with
* d-tag "sovereign_browser"
*
* Returns 0 on success, -1 on error / not applicable.
*/
int settings_sync_merge_from_nostr(const void *event_cjson);
/*
* Update the signer reference (e.g. after switching identity).
*/
void settings_sync_set_signer(nostr_signer_t *signer,
const char *pubkey_hex);
#ifdef __cplusplus
}
#endif
#endif /* SETTINGS_SYNC_H */

238
src/shortcuts.c Normal file
View File

@@ -0,0 +1,238 @@
/*
* shortcuts.c — configurable keyboard shortcuts for sovereign_browser
*
* Bindings are stored in the SQLite key_value table as:
* shortcut.<action_id> = "<GTK accelerator string>"
* e.g. shortcut.new_tab = "<Control>t"
*
* On load, each stored string is parsed via gtk_accelerator_parse() into
* a (keyval, mods) pair kept in a static array. shortcuts_lookup() does
* a linear scan comparing incoming GdkEventKey values.
*/
#include "shortcuts.h"
#include "settings.h"
#include "db.h"
#include <string.h>
#include <stdlib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Registry ───────────────────────────────────────────────────────── */
static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
[SHORTCUT_NEW_TAB] = {
"new_tab", "New tab",
"Open a new tab", "<Control>t"
},
[SHORTCUT_CLOSE_TAB] = {
"close_tab", "Close tab",
"Close the active tab", "<Control>w"
},
[SHORTCUT_FOCUS_URL] = {
"focus_url", "Focus URL bar",
"Focus the URL entry of the active tab", "<Control>l"
},
[SHORTCUT_NEXT_TAB] = {
"next_tab", "Next tab (Ctrl+Tab)",
"Cycle to the next tab", "<Control>Tab"
},
[SHORTCUT_PREV_TAB] = {
"prev_tab", "Previous tab (Ctrl+Shift+Tab)",
"Cycle to the previous tab", "<Control><Shift>Tab"
},
[SHORTCUT_NEXT_TAB_PAGEDOWN] = {
"next_tab_pagedown", "Next tab (PageDown)",
"Switch to the next tab", "<Control>Page_Down"
},
[SHORTCUT_PREV_TAB_PAGEUP] = {
"prev_tab_pageup", "Previous tab (PageUp)",
"Switch to the previous tab", "<Control>Page_Up"
},
[SHORTCUT_RELOAD] = {
"reload", "Reload page",
"Reload the current page", "F5"
},
[SHORTCUT_FORCE_RELOAD] = {
"force_reload", "Force reload",
"Reload bypassing cache", "<Shift>F5"
},
[SHORTCUT_GO_BACK] = {
"go_back", "Go back",
"Navigate back in history", "<Alt>Left"
},
[SHORTCUT_GO_FORWARD] = {
"go_forward", "Go forward",
"Navigate forward in history", "<Alt>Right"
},
[SHORTCUT_FIND] = {
"find", "Find in page",
"Open the find bar", "<Control>f"
},
[SHORTCUT_OPEN_SETTINGS] = {
"open_settings", "Open settings",
"Open the sovereign://settings page", "<Control>comma"
},
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",
"<Control><Shift>i"
},
[SHORTCUT_TOGGLE_FULLSCREEN] = {
"toggle_fullscreen", "Toggle fullscreen",
"Enter or exit fullscreen mode", "F11"
},
};
/* ── In-memory parsed bindings ──────────────────────────────────────── */
typedef struct {
guint keyval;
GdkModifierType mods;
char accel_str[64]; /* current string, for UI + sync */
} parsed_binding_t;
static parsed_binding_t g_parsed[SHORTCUT_COUNT];
/* ── Helpers ────────────────────────────────────────────────────────── */
/* db_kv key for an action: "shortcut.new_tab" */
static void kv_key(shortcut_action_t a, char *buf, size_t buflen) {
snprintf(buf, buflen, "shortcut.%s", g_registry[a].id);
}
/* Parse an accelerator string into the g_parsed slot and update accel_str.
* gtk_accelerator_parse() returns void in GTK3, so we check keyval==0
* to detect a parse failure. */
static int parse_and_store(shortcut_action_t a, const char *accel_str) {
if (accel_str == NULL || accel_str[0] == '\0') return -1;
guint keyval = 0;
GdkModifierType mods = 0;
gtk_accelerator_parse(accel_str, &keyval, &mods);
if (keyval == 0) {
return -1;
}
g_parsed[a].keyval = keyval;
g_parsed[a].mods = mods;
snprintf(g_parsed[a].accel_str, sizeof(g_parsed[a].accel_str),
"%s", accel_str);
return 0;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void shortcuts_load(void) {
/* Start with defaults. */
for (int i = 0; i < SHORTCUT_COUNT; i++) {
const char *dflt = g_registry[i].dflt;
parse_and_store((shortcut_action_t)i, dflt);
}
/* Override with stored values from db_kv. */
for (int i = 0; i < SHORTCUT_COUNT; i++) {
char key[64];
kv_key((shortcut_action_t)i, key, sizeof(key));
const char *val = db_kv_get(key);
if (val && val[0]) {
if (parse_and_store((shortcut_action_t)i, val) != 0) {
/* Bad stored value — keep the default. */
g_printerr("[shortcuts] Bad stored value for %s: %s\n",
key, val);
}
}
}
}
int shortcuts_lookup(GdkEventKey *event) {
if (event == NULL) return -1;
/* Master toggle: if shortcuts are disabled, bail out. */
const browser_settings_t *s = settings_get();
if (!s->ctrl_tab_switch) return -1;
guint event_mods = event->state & gtk_accelerator_get_default_mod_mask();
for (int i = 0; i < SHORTCUT_COUNT; i++) {
if (g_parsed[i].keyval == 0) continue;
if (g_parsed[i].keyval == event->keyval &&
g_parsed[i].mods == event_mods) {
return i;
}
}
return -1;
}
const char *shortcuts_get(shortcut_action_t action) {
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
return g_parsed[action].accel_str;
}
int shortcuts_set(shortcut_action_t action, const char *accel_str) {
if (action < 0 || action >= SHORTCUT_COUNT) return -1;
if (accel_str == NULL) return -1;
if (parse_and_store(action, accel_str) != 0) {
return -1;
}
char key[64];
kv_key(action, key, sizeof(key));
db_kv_set(key, g_parsed[action].accel_str);
return 0;
}
void shortcuts_reset(shortcut_action_t action) {
if (action < 0 || action >= SHORTCUT_COUNT) return;
shortcuts_set(action, g_registry[action].dflt);
}
void shortcuts_reset_all(void) {
for (int i = 0; i < SHORTCUT_COUNT; i++) {
shortcuts_reset((shortcut_action_t)i);
}
}
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action) {
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
return &g_registry[action];
}
int shortcuts_action_from_id(const char *id) {
if (id == NULL) return -1;
for (int i = 0; i < SHORTCUT_COUNT; i++) {
if (strcmp(g_registry[i].id, id) == 0) return i;
}
return -1;
}
/* ── Serialization (for NIP-78 sync) ────────────────────────────────── */
cJSON *shortcuts_serialize(void) {
cJSON *obj = cJSON_CreateObject();
for (int i = 0; i < SHORTCUT_COUNT; i++) {
cJSON_AddStringToObject(obj, g_registry[i].id,
g_parsed[i].accel_str);
}
return obj;
}
void shortcuts_merge_from_json(const cJSON *json) {
if (json == NULL || !cJSON_IsObject(json)) return;
cJSON *item;
cJSON_ArrayForEach(item, json) {
if (!cJSON_IsString(item)) continue;
int action = shortcuts_action_from_id(item->string);
if (action < 0) continue; /* unknown key — ignore */
if (parse_and_store((shortcut_action_t)action,
item->valuestring) == 0) {
char key[64];
kv_key((shortcut_action_t)action, key, sizeof(key));
db_kv_set(key, g_parsed[action].accel_str);
}
}
}

132
src/shortcuts.h Normal file
View File

@@ -0,0 +1,132 @@
/*
* shortcuts.h — configurable keyboard shortcuts for sovereign_browser
*
* Browser-level keyboard shortcuts (new tab, close tab, focus URL, tab
* cycling, reload, back/forward, find, settings, fullscreen) are
* user-configurable. Bindings are stored in the SQLite key_value table
* as "shortcut.<action_id>" = GTK accelerator string (e.g. "<Control>t")
* and synced across devices via NIP-78 (kind 30078) by settings_sync.c.
*
* The on_key_press() handler in main.c calls shortcuts_lookup() on each
* key event and dispatches the returned action.
*/
#ifndef SHORTCUTS_H
#define SHORTCUTS_H
#include <glib.h>
#include <gtk/gtk.h>
/* Forward declaration for cJSON (used in serialize/merge functions). */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── Action registry ────────────────────────────────────────────────── */
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;
/* Metadata for the settings UI. */
typedef struct {
const char *id; /* "new_tab" — used in db_kv key and JSON */
const char *label; /* "New tab" — shown in the settings page */
const char *desc; /* "Open a new tab" — short description */
const char *dflt; /* "<Control>t" — default GTK accelerator */
} shortcut_meta_t;
/* ── Public API ─────────────────────────────────────────────────────── */
/*
* Load all bindings from the SQLite key_value table into the in-memory
* array. Missing keys use the defaults from the registry.
* Call once at startup after db_init() and settings_load().
*/
void shortcuts_load(void);
/*
* Look up which action a key event matches, or -1 if none.
* Applies gtk_accelerator_get_default_mod_mask() to the event state
* and compares against the parsed bindings.
*
* If the master "shortcuts_enabled" setting is false, always returns -1.
*/
int shortcuts_lookup(GdkEventKey *event);
/*
* Get the current accelerator string for an action (e.g. for the
* settings UI or for Nostr sync serialization). Returns a pointer to
* an internal buffer valid until the next shortcuts_set() call.
*/
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.
* Updates the in-memory array immediately and writes to db_kv.
*/
int shortcuts_set(shortcut_action_t action, const char *accel_str);
/*
* Reset an action to its default binding (and persist to db_kv).
*/
void shortcuts_reset(shortcut_action_t action);
/*
* Reset all actions to their default bindings (and persist to db_kv).
*/
void shortcuts_reset_all(void);
/*
* Get the metadata (id, label, desc, default) for an action.
* Returns a pointer to a static struct.
*/
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
/*
* Look up an action by its string id (e.g. "new_tab").
* Returns the action enum, or -1 if not found.
*/
int shortcuts_action_from_id(const char *id);
/*
* Serialize all current bindings to a JSON object suitable for NIP-78
* sync. Returns a newly allocated cJSON object (caller must delete).
* Format: {"new_tab":"<Control>t","close_tab":"<Control>w",...}
*/
cJSON *shortcuts_serialize(void);
/*
* Merge bindings from a parsed JSON object (as produced by
* shortcuts_serialize). For each key that matches a known action id,
* parse the accelerator and update the in-memory binding + db_kv.
* Unknown keys are silently ignored.
*
* json — a cJSON object mapping action id → accelerator string
*/
void shortcuts_merge_from_json(const cJSON *json);
#ifdef __cplusplus
}
#endif
#endif /* SHORTCUTS_H */

View File

@@ -12,11 +12,27 @@
#include "nostr_inject.h"
#include "history.h"
#include "bookmarks.h"
#include "search.h"
#include "version.h"
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
#include "db.h"
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <stdlib.h>
#include <libsoup/soup.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
/* Portable case-insensitive substring search (replaces GNU strcasestr). */
static const char *ci_strstr(const char *haystack, const char *needle) {
if (haystack == NULL || needle == NULL) return NULL;
if (*needle == '\0') return haystack;
size_t nlen = strlen(needle);
for (const char *p = haystack; *p; p++) {
if (strncasecmp(p, needle, nlen) == 0) return p;
}
return NULL;
}
/* ── External callbacks defined in main.c ─────────────────────────── *
* These handle identity-related menu actions that need access to the
@@ -52,21 +68,388 @@ static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
static char *normalize_url(const char *input);
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
static void on_avatar_clicked(GtkButton *btn, gpointer data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── */
/* ── URL bar completion (search dropdown) ─────────────────────────── *
* Each tab's URL entry has a GtkEntryCompletion backed by a GtkListStore.
* The list store has these columns:
*/
enum {
COMPLETION_COL_DISPLAY = 0, /* visible text in the dropdown */
COMPLETION_COL_URL, /* the URL to navigate to (or NULL for
search suggestions — in that case
the display text is the search query) */
COMPLETION_COL_IS_DIRECT, /* TRUE = direct link, FALSE = search
suggestion */
COMPLETION_COL_COUNT
};
/* Per-tab completion state, attached to the GtkEntry via g_object_set_data. */
typedef struct {
GtkListStore *store;
guint suggest_req_id; /* active async suggestion request, 0=none */
char *last_query; /* last query we fetched suggestions for */
} completion_state_t;
static void on_url_changed(GtkEditable *editable, gpointer user_data);
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data);
static void on_suggestions_received(char **suggestions, gpointer user_data);
static void completion_state_free(completion_state_t *cs);
static void rebuild_completion(const char *query, completion_state_t *cs);
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── *
* If the input looks like a URL (has a scheme, a dot with no spaces,
* is localhost, or is an about: page), it's normalized to a full URL.
* Otherwise, it's treated as a search query and sent to the active
* search engine.
*/
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strstr(input, "://") != NULL) {
return g_strdup(input);
/* If it looks like a URL, normalize it. */
if (search_is_url(input)) {
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
if (strncmp(input, "about:", 6) == 0) {
return g_strdup(input);
}
/* sovereign:// internal pages. */
if (strncmp(input, "sovereign://", 12) == 0) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
if (strncmp(input, "about:", 6) == 0) {
return g_strdup(input);
/* Not a URL — treat as a search query. */
return search_build_search_url(input);
}
/* ── URL bar completion (search dropdown) ─────────────────────────── */
#define COMPLETION_MAX_DIRECT 8 /* max history/bookmark results */
#define COMPLETION_MAX_SUGGEST 8 /* max search engine suggestions */
#define COMPLETION_MIN_KEY_LEN 1 /* min chars before dropdown appears */
static void completion_state_free(completion_state_t *cs) {
if (cs == NULL) return;
if (cs->store) g_object_unref(cs->store);
if (cs->suggest_req_id) search_suggest_cancel(cs->suggest_req_id);
g_free(cs->last_query);
g_free(cs);
}
/*
* Extract a short display label from a URL for the dropdown.
* Strips the scheme and leading "www." for readability.
* Returns a newly allocated string.
*/
static char *url_display_label(const char *url, const char *title) {
if (title && title[0] != '\0') {
/* If we have a title, show "title — domain". */
const char *domain = url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
/* Truncate domain at first / */
const char *slash = strchr(domain, '/');
int dlen = slash ? (int)(slash - domain) : (int)strlen(domain);
return g_strdup_printf("%s — %.*s", title, dlen, domain);
}
return g_strdup_printf("https://%s", input);
/* No title — just show the URL with scheme stripped. */
const char *label = url;
if (strncmp(label, "https://", 8) == 0) label += 8;
else if (strncmp(label, "http://", 7) == 0) label += 7;
return g_strdup(label);
}
/*
* Rebuild the completion list store for the given query.
* Called on every keystroke. Populates direct links (history + bookmarks)
* synchronously, then fires an async request for search engine suggestions.
*/
static void rebuild_completion(const char *query, completion_state_t *cs) {
if (cs == NULL || cs->store == NULL) return;
gtk_list_store_clear(cs->store);
if (query == NULL || query[0] == '\0') return;
/* Cancel any pending suggestion request. */
if (cs->suggest_req_id) {
search_suggest_cancel(cs->suggest_req_id);
cs->suggest_req_id = 0;
}
g_free(cs->last_query);
cs->last_query = g_strdup(query);
/* ── Direct links from history ────────────────────────────────── */
char **urls = NULL;
char **titles = NULL;
int count = 0;
db_history_search(query, &urls, &titles, &count, COMPLETION_MAX_DIRECT);
for (int i = 0; i < count; i++) {
char *label = url_display_label(urls[i], titles[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, urls[i],
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
g_free(urls[i]);
g_free(titles[i]);
}
g_free(urls);
g_free(titles);
/* ── Direct links from bookmarks ──────────────────────────────── */
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
int bookmark_added = 0;
for (int d = 0; d < dir_count && bookmark_added < COMPLETION_MAX_DIRECT; d++) {
for (int b = 0; b < dirs[d].count && bookmark_added < COMPLETION_MAX_DIRECT; b++) {
const bookmark_t *bm = &dirs[d].bookmarks[b];
/* Case-insensitive substring search. */
if ((bm->url && ci_strstr(bm->url, query)) ||
(bm->title && ci_strstr(bm->title, query))) {
char *label = url_display_label(bm->url, bm->title);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, bm->url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
bookmark_added++;
}
}
}
/* ── Domain heuristic ─────────────────────────────────────────── *
* If the query has no spaces and no dots, offer to navigate directly
* to <query>.org and <query>.com. This helps first-time visits to
* known domains without going through the search engine. */
if (strchr(query, ' ') == NULL && strchr(query, '.') == NULL &&
strlen(query) > 0 && strlen(query) < 100) {
/* Only add if we don't already have a direct link matching. */
gboolean have_match = FALSE;
GtkTreeIter iter;
if (gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter)) {
do {
gchar *row_url = NULL;
gtk_tree_model_get(GTK_TREE_MODEL(cs->store), &iter,
COMPLETION_COL_URL, &row_url, -1);
if (row_url) {
/* Check if the domain part matches the query. */
const char *domain = row_url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
if (strncasecmp(domain, query, strlen(query)) == 0) {
have_match = TRUE;
}
g_free(row_url);
}
} while (!have_match &&
gtk_tree_model_iter_next(GTK_TREE_MODEL(cs->store), &iter));
}
if (!have_match) {
char *org_url = g_strdup_printf("https://%s.org", query);
char *org_label = g_strdup_printf("🌐 Go to %s.org", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, org_label,
COMPLETION_COL_URL, org_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(org_url);
g_free(org_label);
char *com_url = g_strdup_printf("https://%s.com", query);
char *com_label = g_strdup_printf("🌐 Go to %s.com", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, com_label,
COMPLETION_COL_URL, com_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(com_url);
g_free(com_label);
}
}
/* ── Fire async search engine suggestions ─────────────────────── *
* The suggestions are appended to the store when the async callback
* fires (on_suggestions_received). We only fetch if the query doesn't
* look like a URL (no point suggesting searches for "wikipedia.org"). */
if (!search_is_url(query)) {
cs->suggest_req_id = search_suggest_fetch_async(
query, on_suggestions_received, cs);
}
}
/*
* Callback for async search engine suggestions.
* Appends the suggestions to the completion store. The user_data is
* the completion_state_t. We check that the query hasn't changed since
* we sent the request (stale results are discarded).
*/
static void on_suggestions_received(char **suggestions, gpointer user_data) {
completion_state_t *cs = (completion_state_t *)user_data;
if (cs == NULL || cs->store == NULL) return;
/* Mark the request as completed. */
cs->suggest_req_id = 0;
if (suggestions == NULL) return;
/* Append search suggestions after the direct links.
* For search suggestions, the URL column stores the search query
* itself (not a URL). The is_direct flag is FALSE, so
* on_completion_match_selected knows to build a search URL from it. */
for (int i = 0; suggestions[i] != NULL && i < COMPLETION_MAX_SUGGEST; i++) {
char *label = g_strdup_printf("🔍 %s", suggestions[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, suggestions[i],
COMPLETION_COL_IS_DIRECT, FALSE,
-1);
g_free(label);
}
}
/*
* Called when the user types in the URL entry.
* Rebuilds the completion dropdown.
*/
static void on_url_changed(GtkEditable *editable, gpointer user_data) {
(void)user_data;
/* Only rebuild the completion dropdown when the user is actively
* typing. If the entry doesn't have focus, the text was set
* programmatically (e.g. by a navigation event updating the URL
* bar) and we shouldn't show the dropdown. */
if (!gtk_widget_has_focus(GTK_WIDGET(editable))) return;
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(editable), "completion-state");
if (cs == NULL) return;
rebuild_completion(text, cs);
}
/*
* Key press handler for the URL entry.
*
* When the completion dropdown is visible, Tab navigates down and
* Shift+Tab navigates up through the suggestions (like most browsers).
* Without this, Tab would move focus away from the URL bar.
*
* When the dropdown is not visible, Tab behaves normally (moves focus).
*/
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data) {
(void)user_data;
/* Only intercept Tab when the completion popup is visible. */
if (event->keyval != GDK_KEY_Tab && event->keyval != GDK_KEY_ISO_Left_Tab)
return FALSE;
GtkEntry *entry = GTK_ENTRY(widget);
GtkEntryCompletion *completion = gtk_entry_get_completion(entry);
if (completion == NULL) return FALSE;
/* Check if the completion popup is visible. GtkEntryCompletion
* doesn't have a direct "is popup visible" API, but we can check
* if there's a completion selection by looking at the tree model.
* If the store has items, we intercept Tab to navigate. */
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(entry), "completion-state");
if (cs == NULL || cs->store == NULL) return FALSE;
GtkTreeIter iter;
if (!gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter))
return FALSE; /* No items in the store — let Tab do its normal thing. */
/* Synthesize a Down or Up arrow key event and forward it to the
* entry. GtkEntryCompletion intercepts arrow keys to navigate
* the dropdown selection. */
GdkEventKey *synth = (GdkEventKey *)gdk_event_new(GDK_KEY_PRESS);
synth->window = g_object_ref(event->window);
synth->send_event = TRUE;
synth->time = event->time;
synth->state = 0; /* No modifiers for the arrow key. */
synth->keyval = (event->state & GDK_SHIFT_MASK)
? GDK_KEY_Up
: GDK_KEY_Down;
synth->length = 0;
synth->string = NULL;
synth->hardware_keycode = 0;
synth->group = 0;
/* Forward the synthetic event to the entry widget. */
gtk_main_do_event((GdkEvent *)synth);
gdk_event_free((GdkEvent *)synth);
return TRUE; /* We handled the Tab — don't move focus. */
}
/*
* Called when the user selects an item from the completion dropdown.
* Navigates to the selected URL (direct link) or builds a search URL
* (for search suggestions).
*/
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data) {
(void)completion;
tab_info_t *tab = (tab_info_t *)user_data;
gchar *display = NULL;
gchar *url = NULL;
gboolean is_direct = FALSE;
gtk_tree_model_get(model, iter,
COMPLETION_COL_DISPLAY, &display,
COMPLETION_COL_URL, &url,
COMPLETION_COL_IS_DIRECT, &is_direct,
-1);
char *navigate_url = NULL;
if (is_direct && url != NULL) {
/* Direct link — navigate to the URL. */
navigate_url = g_strdup(url);
} else if (url != NULL) {
/* Search suggestion — the URL column stores the search query.
* Build a search URL from it. */
navigate_url = search_build_search_url(url);
} else {
/* Fallback: no URL stored. Use the display text as the query. */
navigate_url = search_build_search_url(display);
}
if (navigate_url) {
webkit_web_view_load_uri(tab->webview, navigate_url);
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), navigate_url);
g_free(navigate_url);
}
g_free(display);
g_free(url);
return TRUE; /* We handled it — don't let GTK do default behavior. */
}
/* ── Tab label widget ─────────────────────────────────────────────── */
@@ -194,6 +577,97 @@ static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
fav_w, fav_h, tab->current_url);
}
/* ── Navigation policy decision ────────────────────────────────────── *
* Handles target="_blank" links and middle-click links by opening them
* in a new tab instead of ignoring them. WebKit fires the "decide-policy"
* signal with a WebKitNavigationPolicyDecision when a link is clicked.
* If the navigation action is a link click with a target that would open
* a new view (WEBKIT_NAVIGATION_TYPE_LINK_CLICKED with a non-current
* browser action), we open a new tab and ignore the default decision.
*/
static gboolean on_decide_policy(WebKitWebView *webview,
WebKitPolicyDecision *decision,
WebKitPolicyDecisionType type,
gpointer user_data) {
(void)webview;
(void)user_data;
if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
return FALSE;
WebKitNavigationPolicyDecision *nav_decision =
WEBKIT_NAVIGATION_POLICY_DECISION(decision);
WebKitNavigationAction *action =
webkit_navigation_policy_decision_get_navigation_action(nav_decision);
if (action == NULL)
return FALSE;
/* Only intercept link clicks, not form submissions or reloads. */
WebKitNavigationType nav_type =
webkit_navigation_action_get_navigation_type(action);
if (nav_type != WEBKIT_NAVIGATION_TYPE_LINK_CLICKED)
return FALSE;
/* Check if this is a new-tab request (target="_blank" or
* middle-click with modifier). webkit_navigation_action_get_request()
* gives us the URI. If the decision's frame name is not the main
* frame, or the user clicked with middle button / Ctrl, open a tab. */
guint button = webkit_navigation_action_get_mouse_button(action);
GdkModifierType mods = webkit_navigation_action_get_modifiers(action);
/* Middle-click or Ctrl+click opens a new tab. */
if (button == 2 || (mods & GDK_CONTROL_MASK)) {
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = webkit_uri_request_get_uri(request);
if (uri && uri[0]) {
tab_manager_new_tab(uri);
}
webkit_policy_decision_ignore(decision);
return TRUE;
}
/* Check for target="_blank" — WebKit requests a new view for these.
* The navigation policy decision has a "frame name" that is non-NULL
* when a target is specified. We can't easily get the frame name from
* the API, but WebKit will call "create" on the webview for new
* windows. That's handled separately by the "create" signal.
* For now, let the default navigation proceed. */
return FALSE;
}
/* ── New webview creation (target="_blank") ────────────────────────── *
* When a page requests a new window (target="_blank", window.open()),
* WebKit fires the "create" signal on the webview. We create a new tab
* with the requested URI and return its webview.
*/
static GtkWidget *on_create_webview(WebKitWebView *webview,
WebKitNavigationAction *action,
gpointer user_data) {
(void)webview;
(void)user_data;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = webkit_uri_request_get_uri(request);
/* Create a new tab with the requested URI. tab_manager_new_tab
* creates the webview and loads the URL. We need to return the
* webview widget, so we create the tab and then get its webview. */
int index = tab_manager_new_tab(uri);
if (index < 0) {
/* Max tabs reached — return a dummy webview. */
return GTK_WIDGET(webkit_web_view_new_with_context(g_ctx));
}
tab_info_t *tab = tab_manager_get(index);
if (tab && tab->webview) {
return GTK_WIDGET(tab->webview);
}
return GTK_WIDGET(webkit_web_view_new_with_context(g_ctx));
}
static GtkWidget *build_tab_label(tab_info_t *tab) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
@@ -203,9 +677,9 @@ static GtkWidget *build_tab_label(tab_info_t *tab) {
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
gtk_widget_set_size_request(box, 120, -1); /* min width for readability */
/* Favicon — starts as placeholder, updated via notify::favicon signal. */
tab->favicon = gtk_image_new_from_icon_name("text-html",
GTK_ICON_SIZE_MENU);
/* Favicon — starts empty (no placeholder icon). Updated via the
* notify::favicon signal when a real favicon is available. */
tab->favicon = gtk_image_new();
gtk_widget_set_valign(tab->favicon, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->favicon, FALSE, FALSE, 0);
@@ -352,6 +826,11 @@ static void on_load_changed(WebKitWebView *webview,
if (load_event == WEBKIT_LOAD_COMMITTED) {
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
/* Clear the favicon — the new page's favicon (if any) will
* arrive via the notify::favicon signal. This prevents a
* stale favicon from the previous page lingering. */
gtk_image_clear(GTK_IMAGE(tab->favicon));
/* Show an empty URL bar for blank pages so the user can
* immediately type a URL. Keep current_url as the real URI
* (about:blank) so session save/duplicate still work. */
@@ -419,6 +898,88 @@ static gboolean on_load_failed(WebKitWebView *webview,
return FALSE;
}
/* ── Web view context menu (right-click) ──────────────────────────── */
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
static void on_context_open_in_new_tab(GSimpleAction *action,
GVariant *parameter,
gpointer user_data) {
(void)action;
(void)parameter;
char *url = (char *)user_data;
if (url && url[0]) {
tab_manager_new_tab(url);
}
g_free(url);
}
/* Context menu signal handler for the webview. Appends "Open in New Tab"
* to the default WebKit context menu — either "Open Link in New Tab" if
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
* page background. */
static gboolean on_webview_context_menu(WebKitWebView *webview,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test,
gpointer user_data) {
(void)webview;
(void)event;
(void)user_data;
/* Determine what was clicked. */
const char *url = NULL;
const char *label = NULL;
int is_link = 0;
if (webkit_hit_test_result_context_is_link(hit_test)) {
url = webkit_hit_test_result_get_link_uri(hit_test);
label = "Open Link in New Tab";
is_link = 1;
} else if (!webkit_hit_test_result_context_is_image(hit_test) &&
!webkit_hit_test_result_context_is_media(hit_test) &&
!webkit_hit_test_result_context_is_editable(hit_test) &&
!webkit_hit_test_result_context_is_selection(hit_test) &&
!webkit_hit_test_result_context_is_scrollbar(hit_test)) {
/* Not a link/image/media/editable/selection/scrollbar — it's the
* page background (document context). */
url = webkit_web_view_get_uri(webview);
label = "Open Page in New Tab";
}
if (url == NULL || url[0] == '\0') {
return FALSE; /* Let the default menu show without our item. */
}
/* Build our custom "Open in New Tab" item. */
GSimpleAction *action = g_simple_action_new("open-in-new-tab", NULL);
char *url_copy = g_strdup(url);
g_signal_connect_data(action, "activate",
G_CALLBACK(on_context_open_in_new_tab), url_copy,
(GClosureNotify)g_free, 0);
WebKitContextMenuItem *item =
webkit_context_menu_item_new_from_gaction(G_ACTION(action), label,
NULL);
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
* Tab" at position 1 — between "Open Link" and "Open Link in New
* Window" — so the order is:
* Open Link
* Open Link in New Tab
* Open Link in New Window
*
* For page background: insert at position 0 (top). */
int insert_pos = is_link ? 1 : 0;
webkit_context_menu_insert(context_menu, item, insert_pos);
/* The action is owned by the menu item now. g_object_unref is safe
* because the WebKitContextMenuItem holds its own ref. */
g_object_unref(action);
return FALSE; /* Let WebKit show the menu. */
}
/* ── Hamburger menu callbacks (webview-specific) ──────────────────── */
static void on_menu_reload(GtkMenuItem *item, gpointer data) {
@@ -871,6 +1432,8 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
gtk_widget_set_name(button, "hamburger-btn");
gtk_widget_set_size_request(button, 28, 28); /* fixed square */
return button;
}
@@ -1001,6 +1564,42 @@ static tab_info_t *tab_create(const char *url) {
}
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
/* ── URL bar completion (search dropdown) ────────────────────── *
* Create a GtkListStore and GtkEntryCompletion for this tab's URL
* entry. The store is populated on each keystroke with direct links
* (history + bookmarks + domain heuristic) and async search engine
* suggestions. */
completion_state_t *cs = g_new0(completion_state_t, 1);
cs->store = gtk_list_store_new(COMPLETION_COL_COUNT,
G_TYPE_STRING, /* display */
G_TYPE_STRING, /* url */
G_TYPE_BOOLEAN);/* is_direct*/
cs->suggest_req_id = 0;
cs->last_query = NULL;
GtkEntryCompletion *completion = gtk_entry_completion_new();
gtk_entry_completion_set_model(completion,
GTK_TREE_MODEL(cs->store));
gtk_entry_completion_set_text_column(completion,
COMPLETION_COL_DISPLAY);
gtk_entry_completion_set_minimum_key_length(completion,
COMPLETION_MIN_KEY_LEN);
/* Use a custom match function so all rows in the store are shown
* (we already filtered them in rebuild_completion). The default
* match function would re-filter by the display column, which we
* don't want. */
gtk_entry_completion_set_match_func(completion,
(GtkEntryCompletionMatchFunc)gtk_true, NULL, NULL);
g_signal_connect(completion, "match-selected",
G_CALLBACK(on_completion_match_selected), tab);
gtk_entry_set_completion(GTK_ENTRY(tab->url_entry), completion);
g_object_unref(completion);
/* Store the completion state on the entry for retrieval in
* on_url_changed. Free it when the entry is destroyed. */
g_object_set_data_full(G_OBJECT(tab->url_entry), "completion-state",
cs, (GDestroyNotify)completion_state_free);
/* Bookmark button — right of the URL entry. Opens a directory picker
* dialog to bookmark the current page. */
GtkWidget *bookmark_btn = gtk_button_new();
@@ -1027,12 +1626,24 @@ static tab_info_t *tab_create(const char *url) {
/* Wire signals. */
g_signal_connect(tab->url_entry, "activate",
G_CALLBACK(on_url_activate), tab);
g_signal_connect(tab->url_entry, "changed",
G_CALLBACK(on_url_changed), tab);
/* key-press-event must be connected BEFORE the default handler so
* we can intercept Tab before GtkEntry processes it. */
g_signal_connect(tab->url_entry, "key-press-event",
G_CALLBACK(on_url_key_press), tab);
g_signal_connect(tab->webview, "load-changed",
G_CALLBACK(on_load_changed), tab);
g_signal_connect(tab->webview, "load-failed",
G_CALLBACK(on_load_failed), NULL);
g_signal_connect(tab->webview, "notify::favicon",
G_CALLBACK(on_favicon_changed), tab);
g_signal_connect(tab->webview, "context-menu",
G_CALLBACK(on_webview_context_menu), tab);
g_signal_connect(tab->webview, "decide-policy",
G_CALLBACK(on_decide_policy), tab);
g_signal_connect(tab->webview, "create",
G_CALLBACK(on_create_webview), tab);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
@@ -1041,6 +1652,203 @@ static tab_info_t *tab_create(const char *url) {
return tab;
}
/* ── User avatar (far left of tab bar) ────────────────────────────── *
* Shows the user's Nostr profile picture as a circular avatar on the
* far left of the tab bar, matching the size/shape of the hamburger
* menu button. Falls back to a default avatar icon if no picture is
* available or the download fails.
*/
static GtkWidget *g_avatar = NULL;
/* Scale a pixbuf to fill the given size, center-cropping to preserve
* aspect ratio (like CSS object-fit: cover), then round the corners
* to match the button's border-radius. Returns a new pixbuf (caller
* must unref). */
#define AVATAR_BORDER_RADIUS 4
static GdkPixbuf *make_fitted_pixbuf(GdkPixbuf *src, int size) {
if (src == NULL) return NULL;
int w = gdk_pixbuf_get_width(src);
int h = gdk_pixbuf_get_height(src);
if (w <= 0 || h <= 0) return NULL;
/* Scale so the smaller dimension fills `size`, then center-crop
* the larger dimension. This is object-fit: cover. */
double scale = (double)size / (w < h ? w : h);
int scaled_w = (int)(w * scale + 0.5);
int scaled_h = (int)(h * scale + 0.5);
if (scaled_w < size) scaled_w = size;
if (scaled_h < size) scaled_h = size;
GdkPixbuf *scaled = gdk_pixbuf_scale_simple(src, scaled_w, scaled_h,
GDK_INTERP_BILINEAR);
if (scaled == NULL) return NULL;
/* Center-crop to size×size. */
int xoff = (scaled_w - size) / 2;
int yoff = (scaled_h - size) / 2;
GdkPixbuf *cropped = gdk_pixbuf_new_subpixbuf(scaled, xoff, yoff,
size, size);
g_object_unref(scaled);
if (cropped == NULL) return NULL;
/* Round the corners using a cairo rounded-rectangle clip, matching
* the button's border-radius (AVATAR_BORDER_RADIUS). */
cairo_surface_t *surface = cairo_image_surface_create(
CAIRO_FORMAT_ARGB32, size, size);
cairo_t *cr = cairo_create(surface);
/* Draw a rounded-rectangle path and clip. */
double r = AVATAR_BORDER_RADIUS;
cairo_new_path(cr);
cairo_arc(cr, size - r, r, r, -G_PI_2, 0); /* top-right */
cairo_arc(cr, size - r, size - r, r, 0, G_PI_2); /* bottom-right */
cairo_arc(cr, r, size - r, r, G_PI_2, G_PI); /* bottom-left */
cairo_arc(cr, r, r, r, G_PI, 1.5 * G_PI); /* top-left */
cairo_close_path(cr);
cairo_clip(cr);
/* Paint the pixbuf onto the clipped surface. */
gdk_cairo_set_source_pixbuf(cr, cropped, 0, 0);
cairo_paint(cr);
cairo_destroy(cr);
/* Convert the surface back to a pixbuf. */
GdkPixbuf *result = gdk_pixbuf_get_from_surface(surface, 0, 0,
size, size);
cairo_surface_destroy(surface);
g_object_unref(cropped);
return result;
}
/* Thread data for avatar download. */
typedef struct {
char *url;
int size;
} avatar_fetch_t;
/* Idle callback to set the avatar pixbuf on the widget. */
static gboolean avatar_set_pixbuf_idle(gpointer data) {
GdkPixbuf *pixbuf = (GdkPixbuf *)data;
if (g_avatar && pixbuf) {
gtk_image_set_from_pixbuf(GTK_IMAGE(g_avatar), pixbuf);
g_object_unref(pixbuf);
}
return G_SOURCE_REMOVE;
}
/* Reworked fetch thread that uses the idle callback properly. */
static gpointer avatar_fetch_thread_v2(gpointer data) {
avatar_fetch_t *af = (avatar_fetch_t *)data;
GdkPixbuf *pixbuf = NULL;
if (g_str_has_prefix(af->url, "file://")) {
const char *path = af->url + 7;
pixbuf = gdk_pixbuf_new_from_file_at_size(path, af->size,
af->size, NULL);
} else if (g_str_has_prefix(af->url, "http://") ||
g_str_has_prefix(af->url, "https://")) {
SoupSession *session = soup_session_new();
SoupMessage *msg = soup_message_new("GET", af->url);
GBytes *bytes = soup_session_send_and_read(session, msg, NULL, NULL);
if (bytes != NULL) {
gsize len = 0;
const guchar *data_ptr = g_bytes_get_data(bytes, &len);
if (data_ptr && len > 0) {
GInputStream *stream = g_memory_input_stream_new_from_data(
data_ptr, len, NULL);
pixbuf = gdk_pixbuf_new_from_stream_at_scale(
stream, af->size, af->size, TRUE, NULL, NULL);
g_object_unref(stream);
}
g_bytes_unref(bytes);
}
g_object_unref(msg);
g_object_unref(session);
}
if (pixbuf != NULL) {
GdkPixbuf *fitted = make_fitted_pixbuf(pixbuf, af->size);
g_object_unref(pixbuf);
if (fitted != NULL) {
g_idle_add(avatar_set_pixbuf_idle, fitted);
}
}
g_free(af->url);
g_free(af);
return NULL;
}
/* Set the avatar from the user's pubkey. Queries the kind 0 profile
* from SQLite and starts a background download of the picture.
* Called from main.c after login. */
void tab_manager_set_avatar(const char *pubkey_hex) {
if (g_avatar == NULL || pubkey_hex == NULL || pubkey_hex[0] == '\0') {
if (g_avatar) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
}
return;
}
/* Query the kind 0 profile from SQLite. */
cJSON *kind0 = db_get_latest_event(pubkey_hex, 0);
if (kind0 == NULL) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
return;
}
const char *picture = NULL;
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
cJSON *meta = cJSON_Parse(content->valuestring);
if (meta) {
cJSON *pic = cJSON_GetObjectItemCaseSensitive(meta, "picture");
if (cJSON_IsString(pic) && pic->valuestring[0]) {
picture = g_strdup(pic->valuestring);
}
cJSON_Delete(meta);
}
}
cJSON_Delete(kind0);
if (picture == NULL) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
return;
}
/* Start a background thread to download and process the avatar. */
avatar_fetch_t *af = g_new(avatar_fetch_t, 1);
af->url = g_strdup(picture);
af->size = 28; /* match the fixed button size (28x28) */
g_thread_new("avatar-fetch", avatar_fetch_thread_v2, af);
g_free((char *)picture);
}
/* ── Avatar button — opens the profile page ───────────────────────── */
static void on_avatar_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
/* Open sovereign://profile in the active tab, or a new tab if
* none exists. */
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
}
/* ── New tab button ───────────────────────────────────────────────── */
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
@@ -1076,6 +1884,50 @@ void tab_manager_init(GtkContainer *parent,
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn,
GTK_PACK_END);
/* User avatar as an action widget at the start (far left) of the
* tab strip. Uses a GtkButton with GTK_RELIEF_NORMAL to match the
* hamburger menu button's visible border. margin_start matches the
* toolbar's left margin (4px) so the avatar's left edge aligns
* horizontally with the hamburger button's left edge.
* The inner GtkImage (g_avatar) is updated via tab_manager_set_avatar(). */
g_avatar = gtk_image_new_from_icon_name("avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
GtkWidget *avatar_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(avatar_btn), GTK_RELIEF_NORMAL);
gtk_button_set_image(GTK_BUTTON(avatar_btn), g_avatar);
gtk_widget_set_tooltip_text(avatar_btn, "Your profile");
gtk_widget_set_valign(avatar_btn, GTK_ALIGN_CENTER);
gtk_widget_set_margin_start(avatar_btn, 4);
gtk_widget_set_name(avatar_btn, "avatar-btn");
gtk_widget_set_size_request(avatar_btn, 28, 28); /* fixed square, matches hamburger */
g_signal_connect(avatar_btn, "clicked",
G_CALLBACK(on_avatar_clicked), NULL);
gtk_widget_show_all(avatar_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), avatar_btn,
GTK_PACK_START);
/* CSS: remove internal padding from both buttons so their contents
* fill edge-to-edge. Force both to a fixed square size (28x28) and
* restore rounded corners so the avatar image is clipped to the
* button's rounded shape. Sizes are also set via
* gtk_widget_set_size_request() in C. */
GtkCssProvider *css = gtk_css_provider_new();
gtk_css_provider_load_from_data(css,
"#avatar-btn, #hamburger-btn {"
" padding: 0px;"
" min-width: 28px; min-height: 28px;"
" border-radius: 4px;"
"}"
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}",
-1, NULL);
gtk_style_context_add_provider_for_screen(
gdk_screen_get_default(),
GTK_STYLE_PROVIDER(css),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(css);
gtk_container_add(parent, g_notebook);
tab_manager_apply_settings();

View File

@@ -142,6 +142,16 @@ void tab_manager_reload(int index);
*/
void tab_manager_apply_settings(void);
/*
* Set the user's avatar on the far left of the tab bar. Queries the
* kind 0 profile from SQLite for the picture URL and downloads it
* in a background thread. Falls back to a default icon if no picture
* is available. Call after login (when the pubkey is known).
*
* pubkey_hex — the user's hex pubkey (64 chars, or NULL for default)
*/
void tab_manager_set_avatar(const char *pubkey_hex);
#ifdef __cplusplus
}
#endif

View File

@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.14"
#define SB_VERSION "v0.0.15"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 14
#define SB_VERSION_PATCH 15
#endif /* SOVEREIGN_BROWSER_VERSION_H */

5
wsb_login.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
# Log into the wsb test account.
./browser.sh restart --login-method nsigner --nsigner-transport qrexec --nsigner-device nostr_signer