Compare commits

...
16 Commits
Author SHA1 Message Date
Laan Tungir 02a92f7a26 v0.0.19 - Fix new window tabs: full tab infrastructure (toolbar, URL bar, tab label, favicon) in new windows, new tabs open in active window's notebook, multi-window tab management 2026-07-13 06:31:16 -04:00
Laan Tungir a722a7cc67 v0.0.18 - Add real new window support for target=_blank and Open in New Window — uses webkit_web_view_new_with_related_view() to share WebProcess, window focus tracking for MCP active webview 2026-07-13 06:09:41 -04:00
Laan Tungir d032cef0ab v0.0.17 - Fix browser crash on target=_blank links (WebKit WindowFeatures assertion), MCP session recovery on browser restart, click_at tool for coordinate-based clicking 2026-07-12 18:28:01 -04:00
Laan Tungir 775bf27ab0 v0.0.16 - MCP server improvements: coordinate-based clicking via GDK events, bounding box ref storage, stale ref fallback, click_at tool, GDK keyboard events for fill/type, NIP-07 bridge fix (sync XHR), page aesthetics matching client, inspector shortcut Ctrl+Shift+I, Night/Day theme toggle 2026-07-12 18:02:28 -04:00
Laan Tungir f01a345b7c 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) 2026-07-12 15:47:35 -04:00
Laan Tungir f91ff74138 v0.0.14 - Migrated all storage to SQLite (history, session, settings), removed key persistence (keys now in-memory only), added back/forward nav buttons, NIP-44 encrypted bookmarks with directories, sovereign://bookmarks page, bookmark toolbar button, sovereign://profile page, QR codes, No-Login mode, bootstrap relay fetch, fixed browser.sh stop killing Roo Code 2026-07-12 13:43:14 -04:00
Laan Tungir 8c75c74c38 v0.0.13 - Consolidated settings into sovereign://settings page, added QR codes (libqrencode), SQLite event storage, No-Login mode, bootstrap relay fetch, sovereign://profile page, and fixed browser.sh stop killing Roo Code 2026-07-12 11:01:36 -04:00
Laan Tungir 372339aa35 v0.0.12 - MCP server: 100 tools, Streamable HTTP transport, favicons, refresh button, webview sizing fix
- MCP protocol compliance: ping, resources/list, prompts/list, logging/setLevel, 202 notifications
- Screenshot tool: WebKit snapshot to PNG to base64 MCP image content
- Session management: Mcp-Session-Id header, SSE response format, GET/DELETE endpoints
- Phase 3: 70 new tools across 8 batches (extended interaction, get/state, find elements, wait/batch, cookies/storage, mouse/clipboard/settings, frames/dialogs/debug, complex)
- Favicon support: enabled WebKitFaviconDatabase, notify::favicon signal handler
- Tab UI: favicons in tab labels, evenly distributed tabs, immediate title from URL host
- Refresh button: left-click reload, right-click hard reload menu (bypass cache, clear site data)
- Webview sizing fix: gtk_widget_set_vexpand/hexpand to prevent 1px height blank page
- Updated Roo Code MCP config (100 tools in alwaysAllow)
- Updated plans: mcp-server.md, agent-tools.md, phase3-tools.md
2026-07-12 09:20:14 -04:00
Laan Tungir c6495beb6d v0.0.11 - Implemented MCP server endpoint at /mcp. All 29 tools exposed with JSON schemas. initialize, tools/list, and tools/call all working via curl. Async tools (snapshot, eval) work via sync fallback when conn=NULL. Both /agent (WebSocket) and /mcp (HTTP) endpoints available. 2026-07-12 06:27:33 -04:00
Laan Tungir 3c9be0da01 v0.0.10 - Converted all JS-based tools to async evaluation: get_text, get_html, get_attr, click, fill, type, hover, focus, press, scroll. All 12 tools now pass end-to-end test: login, open, snapshot, get_text, get_html, get_attr, eval, click (navigated to IANA page), scroll, tab_list, get_url. 2026-07-12 06:02:48 -04:00
Laan Tungir d006d3fa0d v0.0.9 - Fixed snapshot tool: AGENT_SNAPSHOT_JS was self-executing IIFE causing syntax error when wrapped with args. Changed to function definition. Snapshot now works async. Eval works async. Login, open, get_title, tab_list all pass. 2026-07-11 22:09:48 -04:00
Laan Tungir c6af0179b0 v0.0.8 - Fixed WebSocket agent server: g_object_ref on connections to prevent premature destruction. Agent login now auto-closes GTK dialog via timeout check. All 9 login tests pass. 2026-07-11 21:25:29 -04:00
Laan Tungir fc0e412cc5 v0.0.7 - Fixed agent login flow: browser starts normally with GTK dialog, no blocking wait. Agent server is available throughout — agent can log in at any time (during dialog or after browser is running). Agent login takes priority over dialog if both occur. 2026-07-11 21:12:10 -04:00
Laan Tungir d64bd45f54 v0.0.6 - Updated README with agent tools documentation: login tools, browser tools, snapshot+ref workflow, settings, roadmap update 2026-07-11 20:47:05 -04:00
Laan Tungir 5c52198d9e v0.0.5 - Implemented agent tools Phase 1: WebSocket server (libsoup), login tools (local/seed/readonly/nip46/nsigner), snapshot+ref system, 31 browser automation tools (open, click, fill, type, eval, scroll, tabs, etc.), agent-driven login with GTK dialog fallback 2026-07-11 20:46:24 -04:00
Laan Tungir 0160b70dc2 v0.0.4 - Implemented browser tabs with GtkNotebook, per-tab toolbars, session restore, settings dialog, and keyboard shortcuts. Made Other Qube (qrexec) the default n_signer transport. 2026-07-11 20:17:56 -04:00
65 changed files with 21722 additions and 940 deletions
+72
View File
@@ -0,0 +1,72 @@
# Agent Instructions — sovereign_browser
## Browser Management
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never launch `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open.
```bash
./browser.sh start # build + launch (detached, waits for MCP ready)
./browser.sh stop # kill running instance
./browser.sh restart # stop + build + start
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
```
The browser's MCP server runs on `http://localhost:17777/mcp` (Streamable HTTP transport).
## Login
The browser requires Nostr login before any browser tools work. The quickest way to log in is with a random key:
```bash
# Via MCP (curl):
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"random"}}}'
```
Or use the `login` MCP tool with `{"method": "random"}`. This generates a fresh random Nostr keypair and logs in immediately. The response includes the `nsec` so you can save it if needed.
Other login methods: `local` (nsec/hex), `seed` (mnemonic), `readonly` (npub), `nip46` (bunker URL), `nsigner` (hardware signer).
## MCP Server
The browser exposes 100 MCP tools for browser automation:
- **Login tools**: `login_status`, `login`, `logout`, `switch_identity`
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`, `eval`, `screenshot`, `screenshot_annotated`
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`, `hover`, `focus`, `select`, `check`, `uncheck`, `drag`, `keyboard_type`, `insert_text`, `keydown`, `keyup`, `scroll_into_view`
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`, `find_alt`, `find_title`, `find_testid`, `find_first`, `find_last`, `find_nth`
- **Get info**: `get_value`, `get_count`, `get_box`, `get_styles`
- **Check state**: `is_visible`, `is_enabled`, `is_checked`
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`, `wait_for_fn`
- **Batch**: `batch` (execute multiple tool calls in sequence)
- **Cookies & storage**: `cookies_get`, `cookies_set`, `cookies_clear`, `storage_local_*`, `storage_session_*`
- **Mouse**: `mouse_move`, `mouse_down`, `mouse_up`, `mouse_wheel`
- **Clipboard**: `clipboard_read`, `clipboard_write`, `clipboard_copy`, `clipboard_paste`
- **Settings**: `set_viewport`, `set_media`, `set_offline`, `set_headers`, `set_credentials`
- **Frames**: `frame_switch`, `frame_main`
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
- **Debug**: `console`, `errors`, `highlight`
- **State**: `state_save`, `state_load`
- **Files**: `upload`, `pdf`
## Common Workflow
1. `./browser.sh start` (or `restart` if already running)
2. Login: call `login` tool with `{"method": "random"}`
3. Navigate: call `open` tool with a URL
4. Snapshot: call `snapshot` to get the accessibility tree with element refs
5. Interact: use refs (e.g. `@e1`) with `click`, `fill`, etc.
6. Screenshot: call `screenshot` to see the page visually
## Building
```bash
make # build the browser
make clean # clean build artifacts
```
The browser is a WebKitGTK + C99 application. It links against `webkit2gtk-4.1`, `libsoup-3.0`, and `nostr_core_lib` (vendored).
+108
View File
@@ -0,0 +1,108 @@
{
"mcpServers": {
"sovereign-browser": {
"url": "http://localhost:17777/mcp",
"type": "streamable-http",
"alwaysAllow": [
"login_status",
"snapshot",
"click",
"click_at",
"screenshot",
"login",
"logout",
"switch_identity",
"open",
"back",
"forward",
"reload",
"get_url",
"get_title",
"get_attr",
"get_value",
"get_count",
"get_box",
"get_styles",
"is_visible",
"is_enabled",
"is_checked",
"eval",
"fill",
"type",
"press",
"hover",
"focus",
"close",
"tab_list",
"tab_new",
"tab_switch",
"tab_close",
"wait",
"wait_for",
"screenshot_annotated",
"pdf",
"upload",
"state_load",
"get_text",
"get_html",
"scroll",
"dblclick",
"select",
"check",
"uncheck",
"scroll_into_view",
"insert_text",
"keydown",
"keyup",
"drag",
"close_all",
"find_role",
"find_label",
"find_placeholder",
"find_alt",
"find_title",
"find_testid",
"find_first",
"find_last",
"find_nth",
"wait_for_text",
"wait_for_url",
"wait_for_load",
"batch",
"cookies_get",
"cookies_set",
"cookies_clear",
"storage_local_get",
"storage_local_get_key",
"storage_local_set",
"storage_local_clear",
"storage_session_get",
"storage_session_get_key",
"storage_session_set",
"storage_session_clear",
"mouse_move",
"mouse_down",
"mouse_up",
"mouse_wheel",
"clipboard_read",
"clipboard_write",
"clipboard_copy",
"clipboard_paste",
"set_viewport",
"set_offline",
"set_headers",
"set_credentials",
"set_media",
"frame_switch",
"frame_main",
"dialog_accept",
"dialog_dismiss",
"dialog_status",
"console",
"errors",
"highlight",
"state_save"
]
}
}
}
+50
View File
@@ -0,0 +1,50 @@
# sovereign_browser — Agent Rules
## Browser Management (CRITICAL)
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never run `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open, which blocks the command runner.
```bash
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
./browser.sh stop # kill running instance
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
```
## Login
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
```bash
# Generate a fresh key and log in immediately — no credentials needed
./browser.sh start --login-method generate
# Generate a key and open a URL in one command
./browser.sh start --login-method generate --url https://example.com
# Log in with a local key (nsec)
./browser.sh start --login-method local --nsec nsec1...
# Read-only mode (npub, no signing)
./browser.sh start --login-method readonly --npub npub1...
```
Other login methods: `seed` (BIP-39 mnemonic), `nip46` (bunker:// URL), `nsigner` (hardware signer). Run `./sovereign_browser --help` for the full flag list.
Alternatively, you can still log in at runtime via the MCP `login` tool with `{"method": "generate"}` (or `local`, `seed`, `readonly`, `nip46`, `nsigner`).
## MCP Server
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
- See `.roo/agents.md` for the full tool list and workflow
## Building
```bash
make # build
make clean # clean
```
WebKitGTK + C99. Links `webkit2gtk-4.1`, `libsoup-3.0`, `nostr_core_lib` (vendored).
+3 -3
View File
@@ -6,18 +6,18 @@
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1)
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
CFLAGS += -I./nostr_core_lib
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
LDFLAGS ?=
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1)
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
# nostr_core_lib static library + its dependencies
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 := 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)
+188 -8
View File
@@ -41,8 +41,9 @@ because trust moves to the layer where it belongs: your keys.
## Current status
Working proof-of-concept: a C99 + WebKitGTK window with a URL bar that loads
real HTTPS pages. Verified loading `https://laantungir.net` cleanly. See
Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar
per tab, Nostr login, and `window.nostr` injection. Verified loading
`https://laantungir.net` cleanly. See
[`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction
report from the POC phase (and the Servo fallback exploration).
@@ -60,6 +61,84 @@ make
./sovereign_browser [url]
```
## Command-line flags
Run `./sovereign_browser --help` for the full list. Flags are parsed before
GTK init, so they don't conflict with GTK's own options. Flags override
`~/.sovereign_browser/settings.conf` for the current run only (nothing is
written to disk).
### Browser / Startup
| Flag | Description |
|---|---|
| `[URL...]` / `--url <url>` | Open URL(s) in tabs at startup (repeatable) |
| `--new-tab-url <url>` | Override the default new-tab URL |
| `--no-session-restore` | Skip session restore for this run |
| `--session-restore` | Force session restore even if disabled |
| `--max-tabs <n>` | Override max simultaneous tabs |
| `-V, --version` | Print version and exit |
| `-h, --help` | Show help and exit |
### Agent Server
| Flag | Description |
|---|---|
| `--port <port>` | Override agent MCP server port (default 17777) |
| `--no-agent` | Disable the agent server for this run |
| `--agent` | Force-enable the agent server |
| `--agent-origin <origin>` | Allowed origin (repeatable) |
### Login (skips the GTK login dialog)
Specify `--login-method` to bypass the interactive login dialog. The method
string matches the MCP `login` tool. Method-specific flags provide
credentials.
| Flag | Description |
|---|---|
| `--login-method <m>` | `generate\|local\|seed\|readonly\|nip46\|nsigner` |
| `--nsec <nsec1...>` | (local) nsec bech32 private key |
| `--privkey <hex>` | (local) 64-char hex private key |
| `--mnemonic <words>` | (seed) BIP-39 mnemonic, quoted |
| `--account <n>` | (seed) BIP-44 account index (default 0) |
| `--npub <npub1...>` | (readonly) npub bech32 public key |
| `--pubkey <hex>` | (readonly) 64-char hex pubkey |
| `--bunker <url>` | (nip46) `bunker://` URL |
| `--nsigner-transport <t>` | (nsigner) `serial\|unix\|tcp\|qrexec` |
| `--nsigner-device <path>` | (nsigner) device path / socket / host:port / qube |
| `--nsigner-service <name>` | (nsigner) qrexec service name |
| `--nsigner-index <n>` | (nsigner) key index (default 0) |
| `--no-save-identity` | Do not persist identity to disk |
| `--login-timeout <ms>` | Override agent login timeout (GTK dialog path) |
### Diagnostics
| Flag | Description |
|---|---|
| `-v, --verbose` | Increase log verbosity (repeatable) |
| `-q, --quiet` | Suppress non-error log output |
### Examples
```bash
# Existing behavior — still works
./sovereign_browser https://example.com
# Generate a fresh key, skip the login dialog, open a page
./sovereign_browser --login-method generate --url https://example.com
# Local key from env, custom agent port
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no agent server, two tabs
./sovereign_browser --login-method readonly --npub npub1... \
--no-agent --url https://a.com --url https://b.com
# Via browser.sh (forwards extra args)
./browser.sh start --login-method generate --url https://example.com
```
## Architecture (summary)
```
@@ -125,15 +204,116 @@ Supported methods:
See [`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for
the full implementation plan.
### Browser tabs
The browser supports multiple tabs via a `GtkNotebook`. Each tab has its own
toolbar (hamburger menu + URL entry) and `WebKitWebView`. All tabs share a
single `WebKitWebContext`, so the `sovereign://` Nostr bridge, security
settings, and TLS policy apply to every tab automatically.
**Tab features:**
- New tab: `Ctrl+T` or the `+` button at the end of the tab strip
- Close tab: `Ctrl+W`, per-tab close button, or middle-click on the tab label
- Switch tabs: `Ctrl+Tab` / `Ctrl+Shift+Tab`, `Ctrl+PageUp` / `Ctrl+PageDown`
- Focus URL bar: `Ctrl+L`
- Right-click tab context menu: New Tab, Close Tab, Close Other Tabs, Close
Tabs to the Right, Duplicate Tab, Reload Tab
- Drag tabs to reorder
- Tab titles update from page load
- Session save/restore: open tabs are saved on exit and restored on next
launch (configurable in Settings)
**Settings dialog** (hamburger menu → Settings…):
- Restore session on startup (default: on)
- New tab URL (default: `https://example.com`)
- Tab bar position: Top / Bottom / Left / Right (default: Top)
- Show tab close buttons (default: on)
- Middle-click to close tab (default: on)
- Ctrl+Tab to switch tabs (default: on)
- Maximum tabs (default: 50)
- Allow drag to reorder tabs (default: on)
Settings are persisted to `~/.sovereign_browser/settings.conf`. Session state
is saved to `~/.sovereign_browser/session.txt`.
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
implementation plan.
### Agent tools
The browser embeds a WebSocket server (using libsoup) that allows external
AI agents to control the browser programmatically. Connect to
`ws://localhost:17777/agent` and send JSON tool commands.
The server starts before the login dialog and remains available throughout
the browser's lifecycle. The browser starts normally with the GTK login
dialog — no blocking wait. An agent can log in at any time: while the dialog
is showing, or after the browser is already running. If an agent logs in
while the dialog is open, the agent's login takes priority.
**Login tools** (available before login):
- `login_status` — check if logged in
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
(hardware signer via serial/unix/tcp/qrexec transport)
- `logout` — clear signer and reset state
- `switch_identity` — change identity (same params as login)
**Browser tools** (available after login):
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
`get_html`, `get_attr`, `eval` (run JavaScript)
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
`focus`, `close`
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
- Wait: `wait` (ms), `wait_for` (selector)
**Snapshot + ref workflow** (same pattern as agent-browser):
```bash
# Using websocat (cargo install websocat)
# 1. Login
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# 2. Open a page
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# 3. Snapshot — get accessibility tree with refs
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# 4. Click by ref
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
```
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
status (running, port, client count, logged in).
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
- `agent_server_enabled` (default: true)
- `agent_server_port` (default: 17777)
- `agent_allowed_origins` (default: `*`)
- `agent_login_timeout_ms` (default: 30000)
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
implementation plan and tool comparison with agent-browser.
## Roadmap
1. ✅ Base browser (WebKitGTK + C99, loads pages)
2. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
3. `window.nostr` injection (`sovereign://` URI scheme bridge)
4.Security strip (disable SOP/CORS, accept any cert, shared context)
5.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
6.`nostr://` content scheme
7. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
3. ✅ Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
4.Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
5.`window.nostr` injection (`sovereign://` URI scheme bridge)
6.Security strip (disable SOP/CORS, accept any cert, shared context)
7. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
8.`nostr://` content scheme
9. ⏳ Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
## License
+1 -1
View File
@@ -1 +1 @@
0.0.3
0.0.19
Executable
+210
View File
@@ -0,0 +1,210 @@
#!/bin/bash
#
# browser.sh — safe start/stop/restart for sovereign_browser
#
# Agents (Roo Code, etc.) should ALWAYS use this script to manage the browser
# process. It fully detaches the GUI from the terminal so the agent's command
# runner doesn't hang waiting for output that never ends.
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill any running instance
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./browser.sh status — check if running + MCP responsive
# ./browser.sh log — tail the browser log
#
# Extra args after start/restart are forwarded to the binary, e.g.:
# ./browser.sh start --login-method generate --url https://example.com
# ./browser.sh restart --no-agent --url https://a.com --url https://b.com
#
# The browser's stdout/stderr go to /tmp/sovereign_browser.log
# The PID is stored in /tmp/sovereign_browser.pid
set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
PORT=17777
# Check if a PID is actually the sovereign_browser process.
# This safety guard prevents us from accidentally killing unrelated
# processes (e.g. Roo Code / VS Code extension host, which connects
# to the MCP server as a client and would otherwise be caught by
# a naive `lsof -ti :PORT` lookup).
is_browser_pid() {
local pid="$1"
[[ -z "$pid" ]] && return 1
kill -0 "$pid" 2>/dev/null || return 1
# /proc/<pid>/comm contains the executable name (truncated to 15 chars).
# For sovereign_browser it's "sovereign_brows" (15-char limit).
local comm
comm=$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]')
[[ "$comm" == "sovereign_browser" || "$comm" == "sovereign_brows" ]]
}
get_pid() {
# Primary: use the PID file (written by cmd_start).
if [[ -f "$PID_FILE" ]]; then
local pid
pid=$(cat "$PID_FILE" 2>/dev/null | head -n1 || echo "")
if [[ -n "$pid" ]] && is_browser_pid "$pid"; then
echo "$pid"
return 0
fi
fi
# Fallback: find the process LISTENing on the port.
# -sTCP:LISTEN matches only the listening socket, never client
# connections (e.g. Roo Code's MCP client). This is critical —
# without it, `lsof -ti :PORT` would also return Roo Code's PID
# and we'd kill the agent driving the browser.
local port_pid
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
echo "$port_pid"
return 0
fi
return 1
}
cmd_stop() {
local stopped_any=0
# Collect candidate PIDs from the PID file and from LISTENing sockets.
# We use -sTCP:LISTEN so we only match the server, never client
# connections (Roo Code / VS Code ext host connect to the MCP server
# and would be killed by a bare `lsof -ti :PORT`).
local candidates=()
if [[ -f "$PID_FILE" ]]; then
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < "$PID_FILE" 2>/dev/null
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
# Deduplicate and kill only verified sovereign_browser PIDs.
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
if is_browser_pid "$pid"; then
echo "[browser] Stopping PID $pid..."
kill "$pid" 2>/dev/null || true
stopped_any=1
fi
done
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
if is_browser_pid "$pid"; then
echo "[browser] Force killing PID $pid..."
kill -9 "$pid" 2>/dev/null || true
fi
done
sleep 1
fi
rm -f "$PID_FILE"
# Final check: is anything still LISTENing on the port?
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "[browser] Warning: process still listening on port $PORT."
else
echo "[browser] Stopped."
fi
return 0
}
cmd_start() {
local extra_args=("$@")
# Don't start if already running
if get_pid >/dev/null 2>&1; then
echo "[browser] Already running (PID $(get_pid)). Use 'restart' to reload."
return 0
fi
# Build first
echo "[browser] Building..."
if ! make -s 2>/dev/null; then
echo "[browser] Build failed!" >&2
return 1
fi
echo "[browser] Build OK."
# Launch fully detached:
# setsid — new session, detached from controlling terminal
# nohup — immune to SIGHUP
# < /dev/null — detach stdin (GUI processes sometimes block on this)
# > $LOG 2>&1 — redirect all output to log file
# & disown — background + remove from job table
echo "[browser] Launching (detached)..."
if [[ ${#extra_args[@]} -gt 0 ]]; then
echo "[browser] Forwarding args: ${extra_args[*]}"
fi
setsid nohup "$BIN" "${extra_args[@]}" < /dev/null > "$LOG" 2>&1 &
local pid=$!
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# Wait for the MCP server to be ready (up to 10 seconds)
echo "[browser] Waiting for MCP server on port $PORT..."
for i in $(seq 1 100); do
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server is ready (PID $pid)."
return 0
fi
sleep 0.1
done
echo "[browser] Warning: MCP server didn't respond within 10s." >&2
echo "[browser] Check log: $LOG" >&2
return 1
}
cmd_restart() {
cmd_stop
sleep 1
cmd_start "$@"
}
cmd_status() {
local pid
if pid=$(get_pid 2>/dev/null); then
echo "[browser] Running (PID $pid)."
# Check MCP
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server: responsive"
else
echo "[browser] MCP server: NOT responding"
fi
else
echo "[browser] Not running."
fi
}
cmd_log() {
if [[ -f "$LOG" ]]; then
tail -50 "$LOG"
else
echo "[browser] No log file at $LOG"
fi
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
*)
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
echo " start/restart forward extra args to the binary." >&2
exit 1
;;
esac
+5 -2
View File
@@ -275,8 +275,11 @@ flowchart LR
### Layers
1. **Host application (C99).** Owns the window, URL bar, tabs, key store, and
the request router. This is where the "reckless" policy lives.
1. **Host application (C99).** Owns the window, tab strip (`GtkNotebook`),
per-tab toolbars (URL bar + hamburger menu), key store, and the request
router. This is where the "reckless" policy lives. Tab management is in
`tab_manager.c`, user preferences in `settings.c`, and session save/restore
in `session.c`.
2. **Request router.** Inspects every outgoing URL:
- `http://` / `https://` → engine's normal network stack (with CORS /
+789
View File
@@ -0,0 +1,789 @@
# Agent Tools Implementation Plan
## Overview
Make sovereign_browser agentically capable by embedding a WebSocket server
in the browser process. External AI agents (LLM tools, scripts, other
processes) connect to `ws://localhost:PORT` and send JSON tool commands to
navigate, inspect, and interact with web pages.
The design is inspired by [agent-browser](https://github.com/vercel-labs/agent-browser),
which uses a **snapshot + ref** workflow: take a snapshot (accessibility tree
with element refs), then interact by ref (click `@e2`, fill `@e3`). This is
the optimal pattern for LLM-driven browser automation.
## Architecture
```mermaid
flowchart TB
subgraph AgentProcess
Agent[AI Agent / Script / CLI]
end
subgraph BrowserProcess
WSServer[WebSocket Server — SoupServer]
ToolDispatch[Tool Dispatcher]
Snapshot[Snapshot Engine]
Tools[Tool Implementations]
TabMgr[Tab Manager]
end
subgraph WebKitGTK
Webview[WebKitWebView — active tab]
JSEval[webkit_web_view_evaluate_javascript]
end
Agent -->|ws://localhost:PORT JSON| WSServer
WSServer --> ToolDispatch
ToolDispatch --> Tools
Tools --> Snapshot
Tools --> TabMgr
Tools -->|JS eval| JSEval
JSEval --> Webview
Snapshot -->|inject + extract| JSEval
TabMgr --> Webview
```
### Key design decisions
0. **Agent tools must use the same code path as the GUI** — Agent tools
should call the same C functions the GUI calls, not a parallel
implementation. This ensures that any error a user would see, the
agent also sees. Debugging WebKit internals is not our concern; our
C code is. If the agent and GUI take different paths, bugs can hide
from the agent. The login tools are the one exception (backend-only,
bypassing the GTK dialog) — all browser tools go through the same
`WebKitWebView` and `tab_manager` APIs.
1. **WebSocket server via libsoup**`SoupServer` with
`soup_server_add_websocket_handler()` provides a production-ready
WebSocket server. libsoup is already linked (WebKitGTK dependency).
No new dependencies, no hand-rolling the WebSocket protocol.
2. **JSON protocol via cJSON** — cJSON is already vendored in
`nostr_core_lib/cjson/`. All tool requests and responses are JSON.
3. **Snapshot + ref pattern** — The core workflow:
- `snapshot` injects a JS script that walks the accessibility tree,
assigns sequential refs (`e1`, `e2`, ...) to interactive elements,
and returns a text representation of the tree.
- `click @e2` looks up the ref in a per-tab ref map, finds the DOM
element, and clicks it via `element.click()`.
- Refs are per-tab and persist until the next `snapshot` call.
4. **Per-tab ref storage** — Each `tab_info_t` gets a ref map (hash table
from ref string → element selector or DOM node reference). Since JS
execution is per-webview, refs are scoped to the tab's webview.
5. **Async JS execution**`webkit_web_view_evaluate_javascript()` is
async. For tools that need the return value (snapshot, get_text, eval),
we use a callback-based approach: the tool injects JS that stores the
result in a known global variable, then reads it back. Alternatively,
we can use a Promise-based approach with a polling mechanism.
6. **Simple HTTP fallback** — The WebSocket server also handles plain HTTP
GET requests to `http://localhost:PORT/` returning a status JSON
(server info, connected clients, tab count). This makes it easy to
discover the server with `curl` without a WebSocket client.
## WebSocket protocol
### Request format
```json
{
"id": 1,
"tool": "snapshot",
"params": {
"interactive": true,
"compact": true
}
}
```
### Response format
```json
{
"id": 1,
"success": true,
"data": {
"snapshot": "- heading \"Example Domain\" [ref=e1] [level=1]\n- link \"More information...\" [ref=e2]",
"refs": {
"e1": {"role": "heading", "name": "Example Domain", "level": 1},
"e2": {"role": "link", "name": "More information...", "href": "https://example.com"}
}
}
}
```
### Error response
```json
{
"id": 1,
"success": false,
"error": {
"code": "ELEMENT_NOT_FOUND",
"message": "No element with ref @e99"
}
}
```
### Push events (server → client)
```json
{
"type": "event",
"event": "load",
"data": {"url": "https://example.com", "title": "Example Domain", "tab": 0}
}
```
```json
{
"type": "event",
"event": "tab_changed",
"data": {"tab": 1, "url": "https://example.com", "title": "Example"}
}
```
## Tool catalog — full comparison with agent-browser
The table below lists every agent-browser CLI command and whether we plan
to implement it. "Phase 1" tools are in the initial implementation; "Phase 2"
tools are deferred but designed for; "N/A" tools don't apply to our
architecture (CDP-specific, cloud providers, etc.).
### Core commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `open <url>` | `open` | 1 | Navigate active tab |
| `click <sel>` | `click` | 1 | By ref or CSS selector |
| `dblclick <sel>` | `dblclick` | 2 | Double-click |
| `focus <sel>` | `focus` | 1 | Focus element |
| `type <sel> <text>` | `type` | 1 | Type into element |
| `fill <sel> <text>` | `fill` | 1 | Clear and fill |
| `press <key>` | `press` | 1 | Press keyboard key |
| `keyboard type <text>` | `keyboard_type` | 2 | Type with real keystrokes |
| `keyboard inserttext <text>` | `insert_text` | 2 | Insert text without key events |
| `keydown <key>` | `keydown` | 2 | Hold key down |
| `keyup <key>` | `keyup` | 2 | Release key |
| `hover <sel>` | `hover` | 1 | Hover element |
| `select <sel> <val>` | `select` | 2 | Select dropdown option |
| `check <sel>` | `check` | 2 | Check checkbox |
| `uncheck <sel>` | `uncheck` | 2 | Uncheck checkbox |
| `scroll <dir> [px]` | `scroll` | 1 | Scroll page |
| `scrollintoview <sel>` | `scroll_into_view` | 2 | Scroll element into view |
| `drag <src> <tgt>` | `drag` | 2 | Drag and drop |
| `upload <sel> <files>` | `upload` | 2 | Upload files |
| `screenshot [path]` | `screenshot` | 1 | Take screenshot |
| `screenshot --annotate` | `screenshot_annotated` | 2 | Annotated with element labels |
| `pdf <path>` | `pdf` | 2 | Save as PDF |
| `snapshot` | `snapshot` | 1 | Accessibility tree with refs |
| `eval <js>` | `eval` | 1 | Run JavaScript |
| `connect <port>` | N/A | — | CDP-specific, we are the server |
| `stream enable` | `stream_enable` | 2 | WebSocket viewport streaming |
| `stream status` | `stream_status` | 2 | Streaming state |
| `stream disable` | `stream_disable` | 2 | Stop streaming |
| `close` | `close` | 1 | Close active tab |
| `close --all` | `close_all` | 2 | Close all tabs |
### Get info commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `get text <sel>` | `get_text` | 1 | Get text content |
| `get html <sel>` | `get_html` | 1 | Get innerHTML |
| `get value <sel>` | `get_value` | 2 | Get input value |
| `get attr <sel> <attr>` | `get_attr` | 1 | Get attribute |
| `get title` | `get_title` | 1 | Get page title |
| `get url` | `get_url` | 1 | Get current URL |
| `get cdp-url` | N/A | — | CDP-specific |
| `get count <sel>` | `get_count` | 2 | Count matching elements |
| `get box <sel>` | `get_box` | 2 | Get bounding box |
| `get styles <sel>` | `get_styles` | 2 | Get computed styles |
### Check state commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `is visible <sel>` | `is_visible` | 2 | Check if visible |
| `is enabled <sel>` | `is_enabled` | 2 | Check if enabled |
| `is checked <sel>` | `is_checked` | 2 | Check if checked |
### Find elements (semantic locators)
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `find role <role> ...` | `find_role` | 2 | By ARIA role |
| `find text <text> ...` | `find_text` | 2 | By text content |
| `find label <label> ...` | `find_label` | 2 | By label |
| `find placeholder <ph> ...` | `find_placeholder` | 2 | By placeholder |
| `find alt <text> ...` | `find_alt` | 2 | By alt text |
| `find title <text> ...` | `find_title` | 2 | By title attr |
| `find testid <id> ...` | `find_testid` | 2 | By data-testid |
| `find first <sel> ...` | `find_first` | 2 | First match |
| `find last <sel> ...` | `find_last` | 2 | Last match |
| `find nth <n> <sel> ...` | `find_nth` | 2 | Nth match |
### Wait commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `wait <selector>` | `wait_for` | 1 | Wait for element visible |
| `wait <ms>` | `wait` | 1 | Wait for time |
| `wait --text "..."` | `wait_for_text` | 2 | Wait for text to appear |
| `wait --url "..."` | `wait_for_url` | 2 | Wait for URL pattern |
| `wait --load networkidle` | `wait_for_load` | 2 | Wait for load state |
| `wait --fn "..."` | `wait_for_fn` | 2 | Wait for JS condition |
### Batch execution
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `batch --json` | `batch` | 2 | Execute multiple commands |
### Clipboard
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `clipboard read` | `clipboard_read` | 2 | Read clipboard |
| `clipboard write "..."` | `clipboard_write` | 2 | Write clipboard |
| `clipboard copy` | `clipboard_copy` | 2 | Copy selection |
| `clipboard paste` | `clipboard_paste` | 2 | Paste from clipboard |
### Mouse control
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `mouse move <x> <y>` | `mouse_move` | 2 | Move mouse |
| `mouse down [button]` | `mouse_down` | 2 | Press button |
| `mouse up [button]` | `mouse_up` | 2 | Release button |
| `mouse wheel <dy> [dx]` | `mouse_wheel` | 2 | Scroll wheel |
### Browser settings
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `set viewport <w> <h>` | `set_viewport` | 2 | Set viewport size |
| `set device <name>` | `set_device` | N/A | Device emulation is CDP-specific |
| `set geo <lat> <lng>` | `set_geo` | N/A | Geolocation emulation |
| `set offline [on\|off]` | `set_offline` | 2 | Toggle offline |
| `set headers <json>` | `set_headers` | 2 | Extra HTTP headers |
| `set credentials <u> <p>` | `set_credentials` | 2 | HTTP basic auth |
| `set media [dark\|light]` | `set_media` | 2 | Emulate color scheme |
### Cookies and storage
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `cookies` | `cookies_get` | 2 | Get all cookies |
| `cookies set <name> <val>` | `cookies_set` | 2 | Set cookie |
| `cookies clear` | `cookies_clear` | 2 | Clear cookies |
| `storage local` | `storage_local_get` | 2 | Get localStorage |
| `storage local <key>` | `storage_local_get_key` | 2 | Get specific key |
| `storage local set <k> <v>` | `storage_local_set` | 2 | Set value |
| `storage local clear` | `storage_local_clear` | 2 | Clear all |
| `storage session` | `storage_session_*` | 2 | Same for sessionStorage |
### Network
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `network route <url>` | `network_route` | N/A | Request interception is CDP-specific |
| `network unroute` | `network_unroute` | N/A | CDP-specific |
| `network requests` | `network_requests` | N/A | CDP-specific |
| `network request <id>` | `network_request_detail` | N/A | CDP-specific |
| `network har start` | `har_start` | N/A | CDP-specific |
| `network har stop` | `har_stop` | N/A | CDP-specific |
### Tabs and windows
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `tab` | `tab_list` | 1 | List tabs |
| `tab new [url]` | `tab_new` | 1 | New tab |
| `tab <n>` | `tab_switch` | 1 | Switch to tab n |
| `tab close [n]` | `tab_close` | 1 | Close tab |
| `window new` | `window_new` | N/A | Multi-window is future roadmap |
### Frames
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `frame <sel>` | `frame_switch` | 2 | Switch to iframe |
| `frame main` | `frame_main` | 2 | Back to main frame |
### Dialogs
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `dialog accept [text]` | `dialog_accept` | 2 | Accept dialog |
| `dialog dismiss` | `dialog_dismiss` | 2 | Dismiss dialog |
| `dialog status` | `dialog_status` | 2 | Check if dialog open |
### Diff
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `diff snapshot` | `diff_snapshot` | N/A | Future, after snapshot is stable |
| `diff screenshot` | `diff_screenshot` | N/A | Future |
| `diff url` | `diff_url` | N/A | Future |
### Debug
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `trace start/stop` | N/A | — | Chrome trace, not available in WebKitGTK |
| `profiler start/stop` | N/A | — | Chrome DevTools profiler |
| `console` | `console` | 2 | View console messages |
| `errors` | `errors` | 2 | View page errors |
| `highlight <sel>` | `highlight` | 2 | Highlight element |
| `inspect` | N/A | — | Opens Chrome DevTools |
| `state save/load/list` | `state_save/load/list` | 2 | Save/restore auth state |
### Navigation
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `back` | `back` | 1 | Go back |
| `forward` | `forward` | 1 | Go forward |
| `reload` | `reload` | 1 | Reload page |
### Setup
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `install` | N/A | — | We bundle WebKitGTK, no install needed |
| `upgrade` | N/A | — | Package manager handles updates |
### Authentication
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `auth save` | N/A | — | We use Nostr identity, not credential vault |
| `auth login` | N/A | — | Nostr login is our native flow |
### Sessions
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `--session <name>` | N/A | — | We have one browser process, tabs instead |
| `--session-name <name>` | N/A | — | Session persistence is our session.c |
| `--profile <path>` | N/A | — | WebKitGTK handles profile data |
| `--state <path>` | `state_save/load` | 2 | Could implement via WebKitGTK data manager |
### Login tools (sovereign_browser-specific, not in agent-browser)
These tools are unique to sovereign_browser. They allow an agent to handle
the Nostr login flow programmatically — essential because the browser
shows a modal login dialog on startup that blocks all other tools until
the user (or agent) signs in.
The agent server starts **before** the login dialog, so the agent can
authenticate without any human interaction. The login tools call the same
`nostr_core_lib` functions that the GTK login dialog uses, bypassing the
dialog entirely.
| Tool | Params | Description |
|------|--------|-------------|
| `login_status` | `{}` | Check if logged in; return method, pubkey, readonly |
| `login` | `{"method": "local", "nsec": "nsec1..."}` | Sign in with local key |
| `login` | `{"method": "seed", "mnemonic": "word1 word2 ...", "passphrase": "..."}` | Sign in with BIP-39 seed phrase |
| `login` | `{"method": "readonly", "npub": "npub1..."}` | Sign in read-only with npub |
| `login` | `{"method": "nip46", "bunker_url": "bunker://..."}` | Sign in with NIP-46 remote signer |
| `login` | `{"method": "nsigner", "transport": "qrexec", "device": "nostr_signer", "service": "qubes.NsignerRpc", "index": 0}` | Sign in with n_signer hardware |
| `logout` | `{}` | Log out and clear signer |
| `switch_identity` | `{"method": "local", "nsec": "nsec1..."}` | Switch to a new identity (same params as login) |
**Login response:**
```json
{
"id": 1,
"success": true,
"data": {
"method": "local",
"pubkey": "a1b2c3...",
"npub": "npub1...",
"readonly": false
}
}
```
**Architecture change:** The agent server must start *before* the login
dialog. In `main.c`, the startup sequence becomes:
1. `gtk_init()` + load settings/history
2. Create the main window (but don't show it yet)
3. **Start the agent server** (so agents can connect)
4. If `agent_server_enabled` and an agent connects within a timeout,
wait for the agent to call `login`
5. Otherwise (no agent, or timeout), show the GTK login dialog as before
6. After login (via agent or dialog), create tabs and show the window
This means the agent server has two states:
- **Pre-login:** Only `login_status`, `login`, `logout` tools are available
- **Post-login:** All browser tools become available
### Summary
| Category | agent-browser commands | Our Phase 1 | Our Phase 2 | Our Phase 3 | N/A |
|----------|----------------------|-------------|-------------|-------------|-----|
| Login (unique) | 0 (8 new) | 8 | 0 | 0 | 0 |
| Core | 29 | 10 | 0 | 14 | 5 |
| Get info | 9 | 4 | 0 | 4 | 1 |
| Check state | 3 | 0 | 0 | 3 | 0 |
| Find elements | 10 | 0 | 0 | 10 | 0 |
| Wait | 6 | 2 | 0 | 4 | 0 |
| Batch | 1 | 0 | 0 | 1 | 0 |
| Clipboard | 4 | 0 | 0 | 4 | 0 |
| Mouse | 4 | 0 | 0 | 4 | 0 |
| Settings | 7 | 0 | 0 | 4 | 3 |
| Cookies/storage | 8 | 0 | 0 | 11 | 0 |
| Network | 6 | 0 | 0 | 0 | 6 |
| Tabs/windows | 5 | 4 | 0 | 0 | 1 |
| Frames | 2 | 0 | 0 | 2 | 0 |
| Dialogs | 3 | 0 | 0 | 3 | 0 |
| Diff | 3 | 0 | 0 | 0 | 3 |
| Debug | 8 | 0 | 0 | 4 | 4 |
| Navigation | 3 | 3 | 0 | 0 | 0 |
| Setup | 2 | 0 | 0 | 0 | 2 |
| Auth | 2 | 0 | 0 | 0 | 2 |
| Sessions | 4 | 0 | 0 | 2 | 2 |
| **Total** | **119 + 8** | **30** | **0** | **70** | **29** |
**Phase 1 (30 tools):** Login tools (8) + the minimal set for agent-driven
browser automation (22) — login, navigate, snapshot, inspect, interact,
and manage tabs, plus `screenshot`. This is enough for an LLM to log in,
browse, read, fill forms, click buttons, and multi-tab. The agent server
starts before login so the agent can authenticate without human
interaction.
**Phase 2 (0 tools):** Reserved for refinements to Phase 1 tools based on
real agent usage — better snapshot formatting, error handling, edge cases.
**Phase 3 (70 tools):** Everything else that has a WebKitGTK equivalent.
The original plan counted 67, but implementation added 3 extra
`storage_session_*` tools (the plan listed storage as 8 local-only tools;
we implemented 11 by adding `storage_session_get`, `storage_session_get_key`,
`storage_session_set`, and `storage_session_clear`). Deferred until Phase 1
is stable and tested with real agents.
**N/A (29 tools):** CDP-specific features (network interception, Chrome
DevTools profiler, trace), cloud provider integrations, and features that
don't map to our architecture (device emulation, multi-window). Some could
be revisited if WebKitGTK adds equivalent APIs.
## Snapshot engine
The snapshot is the heart of the agent system. It injects a JavaScript
script into the active tab's webview that:
1. Walks the DOM accessibility tree (using `TreeWalker` or recursive
`querySelectorAll`)
2. For each interactive element (links, buttons, inputs, selects, textareas,
elements with role/aria-label), assigns a sequential ref (`e1`, `e2`, ...)
3. Stores a mapping from ref → unique CSS selector (for later re-querying)
4. Returns a text representation of the tree:
```
- heading "Example Domain" [ref=e1] [level=1]
- paragraph "This domain is for use in..."
- link "More information..." [ref=e2]
- textbox "Search" [ref=e3]
- button "Submit" [ref=e4]
```
### Ref storage
The ref map is stored as a JSON string in a hidden `window.__agentRefs`
global on the page. When a tool like `click @e2` is called:
1. The tool injects JS: `var el = document.querySelector(window.__agentRefs['e2']); el.click();`
2. If the element is gone (page navigated), the tool returns an error
suggesting a re-snapshot.
### Interactive-only mode
When `interactive: true`, only elements that are focusable or have an
ARIA role are included. This dramatically reduces the snapshot size for
LLM context windows.
## New files
### `src/agent_server.h` / `src/agent_server.c`
```c
/*
* WebSocket server for agent tool commands.
* Uses libsoup SoupServer with a websocket handler at /agent.
*/
void agent_server_start(int port);
void agent_server_stop(void);
int agent_server_get_port(void);
gboolean agent_server_is_running(void);
/* Broadcast a push event to all connected clients. */
void agent_server_emit_event(const char *event_name, cJSON *data);
```
### `src/agent_tools.h` / `src/agent_tools.c`
```c
/*
* Tool dispatch: receives a JSON request, executes the tool,
* returns a JSON response.
*/
/* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*. */
cJSON *agent_tools_dispatch(cJSON *request);
```
### `src/agent_snapshot.h` / `src/agent_snapshot.c`
```c
/*
* Accessibility tree snapshot via JS injection.
* Assigns refs to interactive elements and returns a text tree.
*/
/* The JS script injected for snapshot. Exposed for testing. */
extern const char *AGENT_SNAPSHOT_JS;
/* Parse a snapshot result JSON from the JS execution. */
cJSON *agent_snapshot_parse(const char *js_result);
```
## New files (login-specific)
### `src/agent_login.h` / `src/agent_login.c`
```c
/*
* Agent-driven Nostr login — calls nostr_core_lib directly,
* bypassing the GTK login dialog. Used by the agent server
* so an AI agent can authenticate without human interaction.
*/
/* Check current login state. Returns JSON response. */
cJSON *agent_login_status(void);
/* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields.
* Returns JSON response with pubkey/npub on success. */
cJSON *agent_login(cJSON *params);
/* Log out — clear signer, reset state. Returns JSON response. */
cJSON *agent_logout(void);
/* Switch identity — same as login but frees the old signer first. */
cJSON *agent_switch_identity(cJSON *params);
/* Returns TRUE if logged in (signer or readonly loaded). */
gboolean agent_is_logged_in(void);
```
## Modified files
### `src/settings.h` / `src/settings.c`
Add settings:
- `agent_server_enabled` (bool, default: true)
- `agent_server_port` (int, default: 17777)
- `agent_server_allowed_origins` (string, default: "*" for localhost)
- `agent_login_timeout_ms` (int, default: 30000) — how long to wait
for an agent to log in before falling back to the GTK dialog
### `src/tab_manager.h` / `src/tab_manager.c`
- Add `GHashTable *ref_map` to `tab_info_t` for per-tab element refs
- Emit events to `agent_server` on load, title change, tab switch
### `src/main.c`
Major change to startup sequence:
- Call `agent_server_start()` **before** `do_login()` (not after)
- After starting the server, wait for either:
- An agent to call `login` within `agent_login_timeout_ms`, OR
- The timeout to expire, then show the GTK login dialog
- After login (via agent or dialog), proceed to create tabs and show window
- Call `agent_server_stop()` in `on_window_destroy()`
### `Makefile`
Add `src/agent_server.c src/agent_tools.c src/agent_snapshot.c src/agent_login.c` to `SRC`.
Add `libsoup-3.0` to pkg-config if not already included.
## Implementation order
### Phase 1 — Login + core browser automation
1. **settings** — add agent server settings (port, enabled, allowed origins)
2. **agent_server** — WebSocket server using libsoup SoupServer, JSON protocol
3. **agent_login** — login tools (login_status, login, logout, switch_identity)
that call nostr_core_lib directly, bypassing the GTK dialog
4. **main.c integration (pre-login)** — start agent server before login dialog,
support agent-driven login with fallback to GTK dialog
5. **agent_snapshot** — the JS injection script + ref assignment
6. **agent_tools** — tool dispatch + core tools (open, snapshot, click, fill,
type, eval, get_text, get_html, get_attr, get_url, get_title)
7. **main.c integration (post-login)** — wire browser tools to tab_manager,
emit events (load, tab_changed)
8. **Navigation tools** — back, forward, reload, scroll, press, wait, wait_for
9. **Tab tools** — tab_list, tab_new, tab_switch, tab_close
10. **Interaction tools** — hover, focus, close
11. **Screenshot tool** — WebKitGTK snapshot API
12. **UI status indicator** — show server port in toolbar
13. **Makefile + docs** — build and documentation
### Phase 2 — Refinements
14. Polish Phase 1 tools based on real agent usage
15. Better snapshot formatting and error messages
16. Edge case handling (page navigation during interaction, stale refs)
### Phase 3 — Extended tool catalog (COMPLETE)
17. All remaining tools from the comparison table (70 tools — 67 planned
+ 3 extra `storage_session_*` tools)
18. Find elements, check state, clipboard, mouse control, cookies/storage,
frames, dialogs, console/errors, batch, state save/load, etc.
19. Implemented in 8 batches (see [`plans/phase3-tools.md`](plans/phase3-tools.md)):
- Batch 1: Extended interaction (11 tools)
- Batch 2: Get info + check state (7 tools)
- Batch 3: Find elements (10 tools)
- Batch 4: Wait + batch (5 tools)
- Batch 5: Cookies + storage (11 tools)
- Batch 6: Mouse + clipboard + settings (13 tools)
- Batch 7: Frames + dialogs + debug (10 tools)
- Batch 8: Complex tools (3 tools)
## Testing the tools
The agent server starts before login, so the first thing an agent does is
authenticate. Once logged in, all browser tools become available.
### Login flow
```bash
# Using websocat (install: cargo install websocat)
# Check login status (before logging in)
echo '{"id":1,"tool":"login_status","params":{}}' | websocat ws://localhost:17777/agent
# Login with local key (nsec)
echo '{"id":2,"tool":"login","params":{"method":"local","nsec":"nsec1..."}}' | websocat ws://localhost:17777/agent
# Login read-only with npub
echo '{"id":3,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# Login with seed phrase
echo '{"id":4,"tool":"login","params":{"method":"seed","mnemonic":"abandon abandon abandon ... abandon about"}}' | websocat ws://localhost:17777/agent
# Login with NIP-46 remote signer
echo '{"id":5,"tool":"login","params":{"method":"nip46","bunker_url":"bunker://..."}}' | websocat ws://localhost:17777/agent
# Login with n_signer via Other Qube (qrexec)
echo '{"id":6,"tool":"login","params":{"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}}' | websocat ws://localhost:17777/agent
```
### Browser automation flow (after login)
```bash
# Open a page
echo '{"id":10,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# Snapshot — get accessibility tree with refs
echo '{"id":11,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# Click by ref
echo '{"id":12,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
# Fill an input
echo '{"id":13,"tool":"fill","params":{"ref":"@e3","value":"test@example.com"}}' | websocat ws://localhost:17777/agent
# Get text
echo '{"id":14,"tool":"get_text","params":{"ref":"@e1"}}' | websocat ws://localhost:17777/agent
# List tabs
echo '{"id":15,"tool":"tab_list","params":{}}' | websocat ws://localhost:17777/agent
# Open new tab
echo '{"id":16,"tool":"tab_new","params":{"url":"https://example.org"}}' | websocat ws://localhost:17777/agent
```
### Python example (persistent connection)
```python
import websocket, json
ws = websocket.create_connection("ws://localhost:17777/agent")
# Login first
ws.send(json.dumps({"id": 1, "tool": "login", "params": {
"method": "readonly", "npub": "npub1..."
}}))
print(json.loads(ws.recv()))
# Then browse
ws.send(json.dumps({"id": 2, "tool": "open", "params": {
"url": "https://example.com"
}}))
print(json.loads(ws.recv()))
ws.send(json.dumps({"id": 3, "tool": "snapshot", "params": {
"interactive": True
}}))
print(json.loads(ws.recv()))
```
## Future: Nostr relay integration
Since the WebSocket server is already in the browser process, a future
enhancement could expose Nostr relay connections through the same server.
An agent could subscribe to Nostr events and react to them — e.g., a
remote agent controlling the browser via Nostr DMs. This is a natural
extension of the "sovereign identity" thesis: your Nostr identity controls
your browser, not just your signing.
## Implementation Status
**All phases complete. 100 tools total.**
| Phase | Tools | Status |
|-------|-------|--------|
| Phase 1 — Login + core | 30 | ✅ Complete |
| Phase 2 — Refinements | 0 | ✅ N/A (folded into Phase 1/3) |
| Phase 3 — Extended catalog | 70 | ✅ Complete (8 batches) |
| **Total** | **100** | ✅ |
### Phase 3 batches (all complete)
| Batch | Category | Tools | Count |
|-------|----------|-------|-------|
| 1 | Extended interaction | dblclick, select, check, uncheck, scroll_into_view, keyboard_type, insert_text, keydown, keyup, drag, close_all | 11 |
| 2 | Get info + check state | get_value, get_count, get_box, get_styles, is_visible, is_enabled, is_checked | 7 |
| 3 | Find elements | find_role, find_text, find_label, find_placeholder, find_alt, find_title, find_testid, find_first, find_last, find_nth | 10 |
| 4 | Wait + batch | wait_for_text, wait_for_url, wait_for_load, wait_for_fn, batch | 5 |
| 5 | Cookies + storage | cookies_get, cookies_set, cookies_clear, storage_local_get, storage_local_get_key, storage_local_set, storage_local_clear, storage_session_get, storage_session_get_key, storage_session_set, storage_session_clear | 11 |
| 6 | Mouse + clipboard + settings | mouse_move, mouse_down, mouse_up, mouse_wheel, clipboard_read, clipboard_write, clipboard_copy, clipboard_paste, set_viewport, set_offline, set_headers, set_credentials, set_media | 13 |
| 7 | Frames + dialogs + debug | frame_switch, frame_main, dialog_accept, dialog_dismiss, dialog_status, console, errors, highlight, state_save, state_load | 10 |
| 8 | Complex tools | upload, pdf, screenshot_annotated | 3 |
| **Total** | | | **70** |
### Verification
- `make clean && make` — builds cleanly (only pre-existing warnings in
`login_dialog.c` and `agent_mcp.c`).
- `tools/list` returns exactly 100 tools.
- `initialize` / `ping` / `login_status` work without login.
- Browser tools (`dblclick`, etc.) correctly return `NOT_LOGGED_IN`
before login.
- `batch` dispatches without login; sub-commands enforce login
individually.
- Roo Code MCP config (`mcp_settings.json`) `alwaysAllow` updated with
all 100 tool names.
+265
View File
@@ -0,0 +1,265 @@
# Plan: Nostr-Encrypted Bookmarks with Directories
## Overview
Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into **directories** (folders). A **bookmark button** in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a `sovereign://bookmarks` page.
## Event Design
### Kind 30003 (NIP-51 Bookmark Sets) — one event per directory
Per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md), **kind 30003** is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a `d` tag = directory name.
NIP-51 encryption pattern (line 11):
> Private items are specified in a JSON array that mimics the structure of the event `tags` array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the `.content`.
### Event structure (one per directory)
```
{
"kind": 30003,
"pubkey": "<user pubkey hex>",
"tags": [["d", "<directory name>"]],
"content": "<NIP-44 encrypted JSON array of bookmarks>",
"created_at": <unix timestamp>,
...
}
```
### Plaintext format (encrypted in `content`)
```json
[
["bookmark", "https://example.com", "Example", 1690000000],
["bookmark", "https://nostr.com", "Nostr", 1690000001]
]
```
### Directory model
- Each directory = one kind 30003 event with `d` = directory name.
- A default directory `"General"` holds bookmarks that aren't assigned to a specific folder.
- The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation).
- The list of directory names is derived from the `d` tags of all the user's kind 30003 events — no separate "directory list" event needed.
### Why kind 30003 (sets) instead of kind 10003 (flat list)?
| Feature | Kind 10003 | Kind 30003 |
|---------|-----------|-----------|
| Directories/folders | Not supported (flat list) | **Supported** — each `d` tag is a directory |
| Replaceable | Normal replaceable (one event) | Parameterized replaceable (one event per `d` tag) |
| NIP-51 standard | Yes — "Bookmarks" | Yes — "Bookmark sets" |
| NIP-44 encryption | Documented | Documented (same pattern) |
Since you want directories, **kind 30003** is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups."
```mermaid
flowchart TD
A["User clicks bookmark button"] --> B["Show directory picker dialog"]
B --> C{"Select existing or create new?"}
C -->|Existing| D["Add bookmark to that directory"]
C -->|Create new| E["Enter new directory name"]
E --> D
D --> F["Update in-memory list for that directory"]
F --> G["Serialize directory bookmarks to JSON"]
G --> H["NIP-44 encrypt to self"]
H --> I["Build kind 30003 event with d=directory"]
I --> J["Sign with signer"]
J --> K["Publish to bootstrap relays"]
J --> L["Store in SQLite db_store_event"]
M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"]
N --> O["Store in SQLite"]
O --> P["For each event: decrypt content with NIP-44"]
P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"]
Q --> R["Populate dropdown menu + bookmarks page"]
S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"]
T --> U["Add/edit/delete/move bookmarks between directories"]
U --> V["Save re-encrypt publish affected directory event"]
```
---
## Implementation
### New files: `src/bookmarks.h` / `src/bookmarks.c`
```c
/* bookmarks.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
typedef struct {
char *url;
char *title;
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/* Initialize the bookmarks module. Loads cached bookmarks from SQLite
* and decrypts them if a signer is available. Call after login. */
int bookmarks_init(void);
/* Free the in-memory bookmark list. */
void bookmarks_cleanup(void);
/* Get the list of directories (read-only). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get a specific directory by name (read-only). Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* Add a bookmark to a directory. If the directory doesn't exist, it's
* created. Updates the in-memory list, re-encrypts, publishes a new
* kind 30003 event for that directory, and stores in SQLite.
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
/* Move a bookmark from one directory to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
/* Create a new empty directory. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if directory already exists / error. */
int bookmarks_create_dir(const char *name);
/* Rename a directory. Publishes a new event with the new d-tag and
* deletes the old one (kind 5 deletion event).
* Returns 0 on success, -1 on error. */
int bookmarks_rename_dir(const char *old_name, const char *new_name);
/* Delete a directory. Optionally move bookmarks to "General" first.
* Publishes a kind 5 deletion event for the old directory event.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called internally by bookmarks_init. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */
char *bookmarks_dir_to_json(const char *dir_name);
/* Build and publish the kind 30003 event for a directory.
* Encrypts the directory's bookmarks with NIP-44 (self-to-self),
* signs, publishes to bootstrap relays, and stores in SQLite.
* Returns 0 on success, -1 on error. */
int bookmarks_publish_dir(const char *dir_name);
```
### Bookmark button in the toolbar
In `tab_manager.c`'s `tab_create()`, add a bookmark button to the toolbar (right of the URL entry):
```c
/* Bookmark button — right of the URL entry. */
GtkWidget *bookmark_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
g_signal_connect(bookmark_btn, "clicked",
G_CALLBACK(on_bookmark_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
```
The `on_bookmark_clicked` handler:
1. Gets the active tab's current URL and title.
2. Shows a **directory picker dialog** — a small GTK dialog with:
- A combo box (dropdown) of existing directories + "New Directory…" option
- If "New Directory…" is selected, show a text entry for the new name
- An "Add" button and a "Cancel" button
3. On "Add", calls `bookmarks_add(dir, url, title)`.
4. Shows a brief confirmation (e.g. "Bookmarked to <dir>").
### sovereign://bookmarks page
Add a `handle_bookmarks_page` function in `nostr_bridge.c`:
- Shows directories as a folder list on the left (or as section headings)
- Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons
- "Add Bookmark" form (URL + title + directory dropdown)
- "Create Directory" button
- "Rename Directory" / "Delete Directory" buttons per directory
- Uses `sovereign://bookmarks/add?dir=...&url=...&title=...`, `sovereign://bookmarks/delete?dir=...&url=...`, `sovereign://bookmarks/move?from=...&to=...&url=...`, `sovereign://bookmarks/createdir?name=...`, `sovereign://bookmarks/renamedir?old=...&new=...`, `sovereign://bookmarks/deletedir?name=...` routes
- Styled consistently with settings/profile pages (dark theme, monospace)
- In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks"
### Dropdown menu integration
In `tab_manager.c`'s `build_hamburger_menu()`, add a "Bookmarks" submenu (like "Recents") that:
- Shows directories as sub-submenus (nested GTK menus)
- Each directory submenu lists its bookmarks as clickable items
- Clicking a bookmark navigates the active tab to that URL
- "Manage Bookmarks…" item at the bottom opens `sovereign://bookmarks`
- Rebuilds on each show (like `on_history_menu_show`)
### Relay fetch integration
Add kind 30003 to the relay fetch filter in `relay_fetch.c`:
```c
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* NEW: bookmark sets */
```
### SQLite
The existing `events` + `event_tags` tables store kind 30003 events. The `event_tags` table allows querying by `d` tag to find all directory events for a pubkey. No schema changes needed.
### Makefile
Add `src/bookmarks.c` to `SRC`.
---
## File change summary
| File | Change |
|------|--------|
| `src/bookmarks.h` | NEW — bookmark/directory data structures + API |
| `src/bookmarks.c` | NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management |
| `src/nostr_bridge.c` | Add `sovereign://bookmarks` page + action routes (add/delete/move/createdir/renamedir/deletedir) |
| `src/tab_manager.c` | Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu |
| `src/main.c` | Call `bookmarks_init()` after login, `bookmarks_cleanup()` on shutdown |
| `src/relay_fetch.c` | Add kind 30003 to the bootstrap fetch filter |
| `Makefile` | Add `src/bookmarks.c` to SRC |
---
## Threading and read-only mode
- **Signing required**: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be *read* from SQLite but not *modified*. The bookmark button and add/delete/move UI are disabled.
- **Background publish**: `bookmarks_publish_dir()` calls `synchronous_publish_event_with_progress()` which blocks. Run in a `g_thread_new()` background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async.
- **SQLite thread safety**: `SQLITE_OPEN_FULLMUTEX` already set.
---
## Edge cases
1. **No signer (read-only / no-login)**: bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks."
2. **No cached bookmarks**: empty state — "No bookmarks yet. Click the bookmark button to save a page."
3. **Decryption failure**: show error, treat that directory as empty.
4. **Directory deletion**: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event.
5. **Directory rename**: publish new kind 30003 with new `d` tag + kind 5 deletion for old `d` tag.
6. **Large directories**: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
7. **Conflict resolution**: kind 30003 is parameterized replaceable — latest `created_at` per `d` tag wins. Next relay fetch reconciles.
8. **Default directory**: "General" is always present (created on first bookmark if no directories exist).
+249
View File
@@ -0,0 +1,249 @@
# Browser Tabs Implementation Plan
## Overview
Add full browser tab support to sovereign_browser. Each tab gets its own
toolbar (URL bar + hamburger menu) and WebKitWebView. All tabs share a single
`WebKitWebContext`, so the `sovereign://` Nostr bridge, security settings, and
TLS policy apply automatically to every tab. Tab behavior is configurable via
a new settings module.
## Architecture
```mermaid
flowchart TB
subgraph Window
Notebook[GtkNotebook — tab strip]
NewTabBtn[New Tab Button +]
end
subgraph TabPage
Toolbar[Toolbar: hamburger + URL entry]
Webview[WebKitWebView]
end
Notebook -->|page 0| TabPage
Notebook -->|page 1| TabPage
Notebook -->|page N| TabPage
TabPage -->|shared| Ctx[WebKitWebContext — sovereign bridge, security, TLS]
Ctx --> NostrBridge[nostr_bridge.c]
Ctx --> NostrInject[nostr_inject.c — per-webview]
TabManager[tab_manager.c] -->|manages| Notebook
Settings[settings.c] -->|configures| TabManager
Session[session.c] -->|save/restore| TabManager
```
### Key design decisions
1. **Per-tab toolbar.** Each notebook page is a vertical `GtkBox` containing
the toolbar (hamburger + URL entry) on top and the `WebKitWebView` below.
This is simpler than a shared toolbar that has to swap signal handlers on
tab switch, and it matches the user's preference.
2. **Shared `WebKitWebContext`.** All `WebKitWebView` instances are created
from the same context. The `sovereign://` URI scheme registration, security
manager flags, and TLS error policy are set once on the context and apply
to all tabs. No per-tab re-registration needed.
3. **Per-webview `nostr_inject_setup()`.** The `window.nostr` injection uses
`WebKitUserContentManager`, which is per-webview. Each new tab calls
`nostr_inject_setup()` on its own webview.
4. **`tab_manager.c` owns the `GtkNotebook`.** It provides functions to
create, close, switch, and query tabs. Each tab is tracked in a struct
holding the webview, URL entry, hamburger button, and tab label widget.
5. **Settings module.** A new `settings.c`/`settings.h` persists user
preferences to `~/.sovereign_browser/settings.conf` (simple key=value
text file). Configurable options:
| Setting | Default | Description |
|---------|---------|-------------|
| `restore_session` | `true` | Restore open tabs on next launch |
| `new_tab_url` | `https://example.com` | URL loaded in new tabs |
| `tab_bar_position` | `top` | `top` / `bottom` / `left` / `right` |
| `show_tab_close_buttons` | `true` | Show per-tab close button |
| `middle_click_close` | `true` | Middle-click on tab closes it |
| `ctrl_tab_switch` | `true` | Ctrl+Tab cycles tabs |
| `max_tabs` | `50` | Maximum simultaneous tabs |
| `tab_drag_reorder` | `true` | Allow drag to reorder tabs |
6. **Session module.** `session.c`/`session.h` saves the list of open tab URLs
(one per line) to `~/.sovereign_browser/session.txt` on window destroy, and
restores them on startup if `settings.restore_session` is true.
## New files
### `src/settings.h` / `src/settings.c`
```c
typedef struct {
gboolean restore_session;
char new_tab_url[512];
int tab_bar_position; /* GTK_POS_TOP, etc. */
gboolean show_tab_close_buttons;
gboolean middle_click_close;
gboolean ctrl_tab_switch;
int max_tabs;
gboolean tab_drag_reorder;
} browser_settings_t;
void settings_load(browser_settings_t *out);
void settings_save(const browser_settings_t *s);
const browser_settings_t *settings_get(void); /* global singleton */
```
### `src/tab_manager.h` / `src/tab_manager.c`
```c
typedef struct {
WebKitWebView *webview;
GtkWidget *url_entry;
GtkWidget *hamburger;
GtkWidget *tab_label; /* composite: icon + title + close btn */
char current_url[2048];
char title[256];
} tab_info_t;
void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx);
int tab_manager_new_tab(const char *url);
void tab_manager_close_tab(int index);
void tab_manager_close_active(void);
int tab_manager_get_active_index(void);
tab_info_t *tab_manager_get_active(void);
tab_info_t *tab_manager_get(int index);
int tab_manager_count(void);
void tab_manager_set_title(int index, const char *title);
```
### `src/session.h` / `src/session.c`
```c
void session_save(void); /* called on window destroy */
int session_restore(void); /* called at startup, returns tab count created */
```
## Modified files
### `src/main.c` — major refactor
- Remove the single `webview` / `url_entry` / `toolbar` creation.
- Create `WebKitWebContext` once, configure security + `sovereign://` bridge
on it (existing code moves here, unchanged logic).
- Call `tab_manager_init()` with the window's vbox and the shared context.
- The hamburger menu builder moves into `tab_manager.c` (or a shared helper)
since it now needs to reference the per-tab webview, not a global.
- All menu callbacks (`on_menu_reload`, `on_menu_stop`, `on_menu_inspector`,
`on_menu_open_file`, `on_menu_history_item`) fetch the active tab's webview
via `tab_manager_get_active()` instead of receiving it as a parameter.
- `on_load_changed` and `on_load_failed` are connected per-tab inside
`tab_manager_new_tab()` and update the per-tab URL entry and tab title.
- `on_url_activate` loads the URL into the active tab's webview.
- Keyboard shortcuts: a `key-press-event` handler on the main window checks
for Ctrl+T / Ctrl+W / Ctrl+Tab / Ctrl+Shift+Tab / Ctrl+PageUp / Ctrl+PageDown.
- `on_window_destroy` calls `session_save()` before cleanup.
### `src/nostr_inject.c` — no changes
`nostr_inject_setup()` already takes a `WebKitWebView*` and is called
per-webview. The tab manager calls it for each new tab.
### `src/nostr_bridge.c` — no changes
The bridge is registered on the shared `WebKitWebContext`. All webviews on
that context automatically get the `sovereign://` scheme handler.
### `src/history.c` — no changes
History is already global. All tabs contribute to and read from the same
history list.
### `Makefile`
Add `src/settings.c src/tab_manager.c src/session.c` to `SRC`.
## Tab UI layout
```
┌──────────────────────────────────────────────────────┐
│ [Tab 1 ×] [Tab 2 ×] [Tab 3 ×] [+] │ ← GtkNotebook tab strip
├──────────────────────────────────────────────────────┤
│ [☰] https://example.com____________ │ ← per-tab toolbar
├──────────────────────────────────────────────────────┤
│ │
│ WebKitWebView │ ← per-tab webview
│ │
└──────────────────────────────────────────────────────┘
```
### Tab label widget (composite)
Each tab label is a horizontal `GtkBox`:
```
[favicon] [Title text…] [×]
```
- Favicon: placeholder icon for now (can be wired to WebKit favicon later)
- Title: `GtkLabel` with ellipsize end, max width ~150px
- Close button: `GtkButton` with `window-close-symbolic` icon, only shown if
`settings.show_tab_close_buttons` is true
### Right-click context menu on tab label
- New Tab
- Close Tab
- Close Other Tabs
- Close Tabs to the Right
- Duplicate Tab
- Reload Tab
### Drag reordering
`GtkNotebook` supports drag reordering natively via
`gtk_notebook_set_tab_reorderable(notebook, child, TRUE)`. Enabled when
`settings.tab_drag_reorder` is true.
## Keyboard shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl+T | New tab |
| Ctrl+W | Close active tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+PageUp | Previous tab |
| Ctrl+PageDown | Next tab |
| Ctrl+L | Focus URL bar of active tab |
## Session restore
On startup, if `settings.restore_session` is true and
`~/.sovereign_browser/session.txt` exists and is non-empty, each line is
loaded as a tab URL. If the file is missing or empty, a single tab is opened
with the start URL (from command-line arg or `settings.new_tab_url`).
On window destroy, before `gtk_main_quit()`, the list of open tab URLs is
written to `~/.sovereign_browser/session.txt`.
## Settings UI
A new menu item "Settings…" in the hamburger menu opens a native GTK dialog
(not a web page, to keep it simple and avoid bootstrapping a
`sovereign://settings` renderer). The dialog has checkboxes and a text entry
for each configurable option. On "OK", `settings_save()` is called and
applicable changes (like tab bar position) are applied live.
## Implementation order
1. **settings.c/h** — foundation, no UI yet, just load/save + global singleton
2. **tab_manager.c/h** — core notebook management, per-tab page creation,
tab label widget, new/close/switch
3. **session.c/h** — save/restore using tab_manager API
4. **main.c refactor** — wire up tab_manager, move context setup, rewire
callbacks to use active tab
5. **Tab interactions** — middle-click close, right-click menu, drag reorder
6. **Keyboard shortcuts** — window-level key-press handler
7. **Session save/restore** — integrate into startup and destroy
8. **Settings dialog** — GTK dialog wired to settings module
9. **Makefile + docs** — build and documentation updates
+264
View File
@@ -0,0 +1,264 @@
# CLI Flags Plan — sovereign_browser
## Rename: "random" → "generate"
As part of this work, rename the login method string `"random"` to
`"generate"` everywhere it appears. "random" implies an arbitrary method
choice; "generate" accurately describes what happens — a fresh local key
is generated and used for login.
Affected locations (from `grep`):
| File | Line | Change |
|---|---|---|
| [`src/agent_login.c`](src/agent_login.c:184) | 184 | `make_login_data("random", ...)``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:425) | 425 | error string listing methods |
| [`src/agent_login.c`](src/agent_login.c:440) | 440 | `strcmp(method, "random")``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:451) | 451 | error string listing methods |
| [`src/agent_mcp.c`](src/agent_mcp.c:137) | 137 | `login` tool description + enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:138) | 138 | `login` tool JSON schema enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:145) | 145 | `switch_identity` tool description |
| [`src/agent_mcp.c`](src/agent_mcp.c:146) | 146 | `switch_identity` tool JSON schema enum |
| [`tests/test_agent_login.py`](tests/test_agent_login.py) | — | any test using `method: "random"` |
| [`README.md`](README.md) | — | any documentation referencing "random" |
| `.roorules` | — | the `login` example uses `"method":"random"` |
The internal C function `login_random()` in
[`src/agent_login.c`](src/agent_login.c) can keep its name (internal) or
be renamed to `login_generate()` for consistency — recommend renaming
for clarity.
## Goal
Add command-line flags to `sovereign_browser` so that startup URLs, agent
server settings, session behavior, and — critically — Nostr login can be
controlled without the GTK login dialog. This enables headless/automated
use (agents, CI, scripting) while preserving the existing interactive
flow as the default.
## Current State
- [`main()`](src/main.c:493) reads only `argv[1]` as an optional start URL
([`src/main.c:502`](src/main.c:502)). No `getopt`, no `--help`.
- [`gtk_init(&argc, &argv)`](src/main.c:496) runs first and would consume
GTK's own flags; our parser must run **before** `gtk_init` so we can
strip our flags out of `argv` before GTK sees them (otherwise GTK aborts
on unknown options like `--login-method`).
- Login methods are enumerated in
[`key_store_method_t`](src/key_store.h:19) and exercised by
[`agent_login()`](src/agent_login.h:38) which already accepts a cJSON
params object. The CLI login path can reuse `agent_login()` directly —
it calls `app_set_signer()` and sets the same global state the GTK
dialog would.
- Settings live in [`browser_settings_t`](src/settings.h:25) and are
loaded from `~/.sovereign_browser/settings.conf`. CLI flags should
**override** settings, not replace them.
## Design Principles
1. **Reuse `agent_login()`** for CLI login — it already does everything
the GTK dialog does, returns structured results, and sets global state
via `app_set_signer()`. The CLI parser just builds the cJSON params
object from flags and calls it.
2. **Parse before `gtk_init()`** so GTK doesn't choke on our flags. We
strip recognized flags from `argc/argv` and pass the reduced vector to
`gtk_init()`.
3. **Flags override settings.conf** but do not write to it. A flag is a
one-shot override for this invocation.
4. **Login flags are mutually exclusive at the method level** — specify
one `--login-*` method; method-specific args are validated against it.
5. **`--login-method generate` is the zero-config path** — generates a
fresh key, logs in, and skips the dialog. Ideal for agents/CI.
6. **Backward compatible**`./sovereign_browser https://example.com`
still works as today (positional URL).
7. **Use `getopt_long`** (POSIX, available on Linux, C99-compatible).
No external deps.
## Proposed Flag Set
### Browser / Startup
| Flag | Arg | Description |
|---|---|---|
| `--url <url>` (repeatable) | URL | Open one or more URLs in tabs at startup. Positional URLs (existing behavior) are still accepted and appended after any `--url` flags. |
| `--new-tab-url <url>` | URL | Override `settings.new_tab_url` for this run (used by Ctrl+T / new-tab button). |
| `--no-session-restore` | — | Skip [`session_restore()`](src/session.c) even if `settings.restore_session` is true. |
| `--session-restore` | — | Force session restore even if disabled in settings. |
| `--max-tabs <n>` | int | Override `settings.max_tabs` for this run. |
| `--version`, `-V` | — | Print `SB_VERSION` (from [`src/version.h`](src/version.h)) and exit 0. |
| `--help`, `-h` | — | Print usage and exit 0. |
### Agent Server
| Flag | Arg | Description |
|---|---|---|
| `--port <port>` | int | Override `settings.agent_server_port` (default 17777). |
| `--no-agent` | — | Disable the agent MCP server for this run (overrides `settings.agent_server_enabled`). |
| `--agent` | — | Force-enable the agent server even if disabled in settings. |
| `--agent-origin <origin>` (repeatable) | string | Append to `agent_allowed_origins` for this run. |
### Login (mutually exclusive method flags)
Exactly one of these may be specified. If none is specified, the GTK
login dialog runs as today.
| Flag | Arg | Description |
|---|---|---|
| `--login-method <m>` | `generate\|local\|seed\|readonly\|nip46\|nsigner` | Select login method. Required to use any other `--login-*` flag. `generate` needs no further flags. |
| `--nsec <nsec1...>` | string | (local) nsec bech32 private key. |
| `--privkey <hex>` | string | (local) 64-char hex private key. Alternative to `--nsec`. |
| `--mnemonic <words>` | string | (seed) BIP-39 mnemonic, quoted ("word1 word2 ..."). |
| `--account <n>` | int | (seed) BIP-44 account index, default 0. |
| `--npub <npub1...>` | string | (readonly) npub bech32 public key. |
| `--pubkey <hex>` | string | (readonly) 64-char hex pubkey. Alternative to `--npub`. |
| `--bunker <url>` | URL | (nip46) `bunker://...` remote signer URL. |
| `--nsigner-transport <t>` | `serial\|unix\|tcp\|qrexec` | (nsigner) transport type. |
| `--nsigner-device <path>` | string | (nsigner) device path / socket / host:port / qube. |
| `--nsigner-service <name>` | string | (nsigner) qrexec service name (qrexec transport only). |
| `--nsigner-index <n>` | int | (nsigner) NIP-06 nostr_index, default 0. |
| `--no-save-identity` | — | Do not persist the CLI-provided identity to `~/.sovereign_browser/identity.json` (default: save, matching GTK dialog behavior). |
| `--login-timeout <ms>` | int | Override `settings.agent_login_timeout_ms`. Only meaningful when the GTK dialog is shown (no `--login-method`). |
### Diagnostics
| Flag | Arg | Description |
|---|---|---|
| `--verbose`, `-v` | — | Increase log verbosity (g_print messages). Repeatable for more detail. |
| `--quiet`, `-q` | — | Suppress non-error log output. |
## Usage Examples
```bash
# Existing behavior — still works
./sovereign_browser https://example.com
# Agent / CI: generated key, no dialog, open a page
./sovereign_browser --login-method generate --url https://example.com
# Local key from env, skip dialog, custom agent port
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no session restore, two tabs
./sovereign_browser --login-method readonly --npub npub1... \
--no-session-restore --url https://a.com --url https://b.com
# Seed phrase, account 1
./sovereign_browser --login-method seed \
--mnemonic "abandon abandon abandon ... about" --account 1
# NIP-46 remote signer
./sovereign_browser --login-method nip46 --bunker "bunker://..."
# n_signer hardware via qrexec
./sovereign_browser --login-method nsigner --nsigner-transport qrexec \
--nsigner-device nostr_signer --nsigner-service qubes.NsignerRpc
# Disable agent server, just browse
./sovereign_browser --no-agent --url https://example.com
# Version / help
./sovereign_browser --version
./sovereign_browser --help
```
## Implementation Plan
### Files Touched
| File | Change |
|---|---|
| `src/cli.h` (new) | Declares `cli_args_t` struct and `cli_parse()`. |
| `src/cli.c` (new) | Implements `getopt_long` parsing, validation, usage text, and `cli_login()` wrapper that builds cJSON and calls `agent_login()`. |
| `src/main.c` | Call `cli_parse(&argc, &argv)` **before** `gtk_init()`. Apply overrides to a mutable copy of settings. If `--login-method` was given, call `cli_login()` and skip `do_login()`. Replace `start_url` logic with the `--url` / positional list. |
| `Makefile` | Add `src/cli.o` to `OBJS`. |
| `README.md` | Document the flags. |
| `browser.sh` | Optional: accept extra args after `start`/`restart` and forward them to the binary (e.g. `./browser.sh start --login-method generate`). |
### Todo List
1. Rename "random" → "generate" in [`src/agent_login.c`](src/agent_login.c), [`src/agent_mcp.c`](src/agent_mcp.c), [`tests/test_agent_login.py`](tests/test_agent_login.py), [`README.md`](README.md), and `.roorules`. Rename internal `login_random()``login_generate()`.
2. Create `src/cli.h` with the `cli_args_t` struct and API.
3. Implement `src/cli.c`: `getopt_long` table, validation, `print_usage()`, `cli_login()` wrapper.
4. Wire `cli_parse()` into [`main()`](src/main.c:493) before `gtk_init()`; strip recognized flags from `argv`.
5. Apply CLI overrides to a mutable settings snapshot used for this run (port, agent enabled, max tabs, new-tab URL, session restore).
6. Implement `--url` (repeatable) + positional URL collection; pass list to tab manager at startup.
7. Implement `--login-method` path: build cJSON params, call `agent_login()`, check `success`, skip GTK dialog on success, exit 1 on failure.
8. Implement `--version` / `--help` (print and exit 0 before GTK init).
9. Implement `--no-save-identity` (skip `key_store_save()` on CLI login).
10. Update `Makefile` to compile `src/cli.c`.
11. Update `browser.sh` to forward extra args to the binary.
12. Update `README.md` with a CLI flags section.
13. Add a smoke test: `./sovereign_browser --login-method generate --no-agent --version` style checks (can be a shell test under `tests/`).
### Key Implementation Details
**Parse order:** `cli_parse()` must run before `gtk_init()` because GTK
aborts on unknown `--options`. `getopt_long` with `optind` lets us
repack `argv` so GTK only sees positional URLs. Pattern:
```c
cli_args_t args;
if (cli_parse(&argc, &argv, &args) != 0) {
return EXIT_FAILURE; /* --help / --version / parse error */
}
gtk_init(&argc, &argv); /* sees only positional URLs now */
```
**Login reuse:** `cli_login()` builds the exact cJSON shape documented in
[`agent_login.h`](src/agent_login.h:27) and calls `agent_login()`. On
`success: true`, the global state is already set via `app_set_signer()`,
so we set `g_logged_in = TRUE` and skip `do_login()`. On failure, print
the error message to stderr and exit non-zero.
**Settings override:** Introduce a `settings_apply_cli_overrides(const
cli_args_t *)` that mutates the global singleton in memory (not on disk)
after `settings_load()`. The rest of the code reads via
`settings_get()` unchanged.
**Repeatable `--url`:** Collect into a `GPtrArray` in `cli_args_t`.
After session restore fails (or is skipped), open each URL in its own
tab via `tab_manager_new_tab(url)`. If no URLs given, fall back to
`settings.new_tab_url`.
**`--no-save-identity`:** The GTK dialog path calls `key_store_save()`
after successful login. The CLI path should do the same by default so
the identity persists, but `--no-save-identity` skips it — useful for
ephemeral/CI runs that should not write to `~/.sovereign_browser/`.
## Mermaid: Startup Decision Flow
```mermaid
flowchart TD
A[main: cli_parse] --> B{help or version?}
B -- yes --> Z[print and exit 0]
B -- no --> C[gtk_init with stripped argv]
C --> D[settings_load + apply CLI overrides]
D --> E[agent_server_start unless --no-agent]
E --> F{login-method flag set?}
F -- yes --> G[cli_login: build cJSON, call agent_login]
G --> H{success?}
H -- no --> Y[print error, exit 1]
H -- yes --> I[optionally key_store_save unless --no-save-identity]
F -- no --> J[do_login: GTK dialog as today]
I --> K[session_restore unless --no-session-restore]
J --> K
K --> L{restored or URLs provided?}
L -- URLs --> M[open each --url / positional URL in a tab]
L -- none --> N[open settings.new_tab_url]
M --> O[gtk_main]
N --> O
```
## Open Questions
1. Should `--login-method generate` auto-set `--no-save-identity` by default
(since a generated key is usually ephemeral)? Proposal: **no** — keep
explicit, but document that random keys *will* be saved unless
`--no-save-identity` is passed.
2. Should we support reading `--nsec` / `--mnemonic` from a file path
(e.g. `--nsec-file /run/secrets/nsec`) to avoid leaking secrets in
`ps`/shell history? Proposal: defer to a follow-up; out of scope for
this plan but worth noting.
3. `--headless` (no GTK window, agent server only)? WebKitGTK requires a
display; true headless would need a virtual framebuffer or a non-WebKit
path. Out of scope for this plan.
+342
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.
+78
View File
@@ -0,0 +1,78 @@
# MCP Server Improvements Plan
## Problem Assessment
The sovereign_browser MCP server uses JavaScript-level interaction (JS `element.click()`,
`dispatchEvent`, `document.querySelector`) which has critical limitations:
1. **CSS selectors with special chars** (e.g. `[60vh]`) break in JSON escaping
2. **JS `.click()` doesn't trigger SPA framework handlers** (React synthetic events, Radix UI pointer events)
3. **Stale refs fail completely** instead of re-resolving
4. **No coordinate-based interaction** — everything depends on CSS selectors working
## agent-browser's Approach (CDP)
agent-browser uses Chrome DevTools Protocol:
- `Accessibility.getFullAXTree` → native AX tree with `backend_node_id` per node
- `DOM.getBoxModel(backend_node_id)` → pixel coordinates of element
- `Input.dispatchMouseEvent(x, y)` → real browser-level mouse events
- Stale `backend_node_id` → re-query AX tree by role+name
## Proposed Improvements for sovereign_browser
### 1. Coordinate-based clicking (highest impact, easiest)
Instead of JS `.click()`, get the element's bounding box and synthesize a GDK event:
```c
// In agent_tools.c, the click tool:
// 1. JS: getBoundingClientRect() → {x, y, width, height}
// 2. C: gdk_event_new(GDK_BUTTON_PRESS) at center coordinates
// 3. C: gtk_main_do_event() to dispatch to WebKit
```
This triggers real WebKit hit-testing and full event propagation, matching how
agent-browser's `Input.dispatchMouseEvent` works.
### 2. Fix ref storage — store bounding box, not just CSS selector
At snapshot time, store each ref's:
- `backend_node_id` (WebKit accessible's unique ID)
- `role` + `name` (for re-resolution if stale)
- `bounding_box` (x, y, width, height — for coordinate-based fallback)
- `selector` (CSS selector — as secondary fallback)
### 3. Stale ref fallback — re-resolve by role+name
If the CSS selector fails, re-query the accessibility tree by role+name
(matching agent-browser's `find_node_id_by_role_name` approach).
### 4. Use WebKit's native accessibility tree for snapshots
Instead of walking the DOM in JS, use WebKit's ATK accessibility:
```c
WebKitWebView *wv = ...;
AtkObject *root = webkit_web_view_get_accessible(wv);
// Walk the ATK tree — stable node IDs, no CSS selector issues
```
### 5. Add `click_at(x, y)` tool
A simple coordinate-based click tool that synthesizes GDK events.
Useful when you know the coordinates from a screenshot.
### 6. Fix `fill`/`type` to use GDK keyboard events
Instead of JS `element.value = ...`, use:
- JS to focus the element
- GDK key events to type character by character
- This triggers proper input validation and React onChange handlers
## Priority
1. **Coordinate-based clicking** — fixes the immediate "can't click buttons" problem
2. **Fix ref storage with bounding box** — enables coordinate fallback
3. **Stale ref fallback** — improves reliability
4. **Native AX tree snapshots** — better stability, less JS dependency
5. **GDK keyboard events for typing** — better SPA compatibility
6. **`click_at` tool** — convenience for screenshot-based interaction
+363
View File
@@ -0,0 +1,363 @@
# MCP Server Implementation Plan
## Overview
Add an MCP (Model Context Protocol) endpoint to sovereign_browser's existing
agent server. This allows AI assistants (Roo Code, Claude Code, Cursor, etc.)
to control the browser directly — no custom scripts, no CLI, no separate
process. The browser exposes its tools as MCP tools, and the AI assistant
calls them like any other MCP tool.
## Architecture
```
AI Assistant (Roo Code, Claude Code, etc.)
↕ MCP Streamable HTTP (JSON-RPC over SSE)
sovereign_browser (SoupServer on port 17777)
├── /agent → WebSocket (existing, for scripts/CLI)
├── /mcp → MCP Streamable HTTP (new, for AI assistants)
└── / → HTTP status (existing)
```
Both `/agent` (WebSocket) and `/mcp` (MCP) use the same internal
`agent_tools_dispatch()` function — same code path, same tools, same
behavior. This follows the debugging principle: no parallel
implementations.
## MCP Streamable HTTP transport
MCP supports a "Streamable HTTP" transport where the client sends HTTP
POST requests and the server responds with Server-Sent Events (SSE).
This is ideal for us — we already have a SoupServer running.
### Protocol flow
1. **Client sends POST to `/mcp`** with JSON-RPC body:
```json
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "roo-code", "version": "1.0"}
}}
```
2. **Server responds** with SSE stream containing the result:
```
event: message
data: {"jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "sovereign-browser", "version": "0.0.10"}
}}
```
3. **Client sends `tools/list`**:
```json
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
```
4. **Server responds** with the full tool catalog (30 tools).
5. **Client sends `tools/call`**:
```json
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
"name": "snapshot",
"arguments": {"interactive": true, "compact": true}
}}
```
6. **Server responds** with the tool result.
### Session management
MCP Streamable HTTP uses a session ID (returned in the `Mcp-Session-Id`
header from `initialize`). The client includes this header in subsequent
requests. We store session state (which is minimal — just the session ID
and whether the client has initialized).
## Tool catalog
Each MCP tool has: name, description, and inputSchema (JSON Schema).
### Login tools
```json
{
"name": "login_status",
"description": "Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
"inputSchema": {"type": "object", "properties": {}}
}
```
```json
{
"name": "login",
"description": "Log in to the browser with a Nostr identity. Methods: 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
"inputSchema": {
"type": "object",
"properties": {
"method": {"type": "string", "enum": ["local", "seed", "readonly", "nip46", "nsigner"]},
"nsec": {"type": "string", "description": "nsec1... string (method: local)"},
"privkey_hex": {"type": "string", "description": "64-char hex private key (method: local)"},
"mnemonic": {"type": "string", "description": "12 or 24 BIP-39 words (method: seed)"},
"account": {"type": "integer", "default": 0, "description": "Account index (method: seed)"},
"npub": {"type": "string", "description": "npub1... string (method: readonly)"},
"pubkey_hex": {"type": "string", "description": "64-char hex pubkey (method: readonly)"},
"bunker_url": {"type": "string", "description": "bunker:// URL (method: nip46)"},
"transport": {"type": "string", "enum": ["serial", "unix", "tcp", "qrexec"], "description": "Transport type (method: nsigner)"},
"device": {"type": "string", "description": "Device path, socket name, host:port, or qube name (method: nsigner)"},
"service": {"type": "string", "default": "qubes.NsignerRpc", "description": "Qrexec service name (method: nsigner, transport: qrexec)"},
"index": {"type": "integer", "default": 0, "description": "Key index (method: nsigner)"}
},
"required": ["method"]
}
}
```
```json
{
"name": "logout",
"description": "Log out and clear the current Nostr identity.",
"inputSchema": {"type": "object", "properties": {}}
}
```
```json
{
"name": "switch_identity",
"description": "Switch to a new Nostr identity. Same parameters as login. Frees the old signer first.",
"inputSchema": {"type": "object", "properties": {"method": {"type": "string"}, "nsec": {"type": "string"}, "privkey_hex": {"type": "string"}, "mnemonic": {"type": "string"}, "npub": {"type": "string"}, "bunker_url": {"type": "string"}, "transport": {"type": "string"}, "device": {"type": "string"}, "index": {"type": "integer"}}, "required": ["method"]}
}
```
### Navigation tools
```json
{"name": "open", "description": "Navigate the active tab to a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}
{"name": "back", "description": "Go back in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "forward", "description": "Go forward in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "reload", "description": "Reload the current page (bypassing cache).", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_url", "description": "Get the current URL of the active tab.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_title", "description": "Get the page title of the active tab.", "inputSchema": {"type": "object", "properties": {}}}
```
### Snapshot & inspection tools
```json
{
"name": "snapshot",
"description": "Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements by click, fill, etc. Call this after navigation or page changes to see what's on the page.",
"inputSchema": {
"type": "object",
"properties": {
"interactive": {"type": "boolean", "default": true, "description": "Only show interactive elements (links, buttons, inputs)"},
"compact": {"type": "boolean", "default": true, "description": "Remove empty structural elements"}
}
}
}
```
```json
{
"name": "get_text",
"description": "Get the text content of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string", "description": "Element ref from snapshot (e.g. @e1)"}, "selector": {"type": "string", "description": "CSS selector"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
```
```json
{
"name": "get_html",
"description": "Get the innerHTML of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
```
```json
{
"name": "get_attr",
"description": "Get an attribute of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "attr": {"type": "string", "description": "Attribute name (e.g. href, src, class)"}}, "required": ["attr"]}
}
```
```json
{
"name": "eval",
"description": "Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
"inputSchema": {"type": "object", "properties": {"script": {"type": "string", "description": "JavaScript to execute"}}, "required": ["script"]}
}
```
### Interaction tools
```json
{"name": "click", "description": "Click an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "fill", "description": "Clear an input and fill it with a value.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "type", "description": "Type text into an element (appends to existing value).", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "press", "description": "Press a keyboard key (e.g. Enter, Tab, Escape).", "inputSchema": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}}
{"name": "scroll", "description": "Scroll the page in a direction.", "inputSchema": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "default": 500}}, "required": ["direction"]}}
{"name": "hover", "description": "Hover over an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "focus", "description": "Focus an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "close", "description": "Close the active tab.", "inputSchema": {"type": "object", "properties": {}}}
```
### Tab tools
```json
{"name": "tab_list", "description": "List all open tabs with their URLs and titles.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "tab_new", "description": "Open a new tab, optionally with a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}}}
{"name": "tab_switch", "description": "Switch to a tab by index.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}, "required": ["index"]}}
{"name": "tab_close", "description": "Close a tab by index. If no index, closes the active tab.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}}}
```
### Wait tools
```json
{"name": "wait", "description": "Wait for a specified number of milliseconds.", "inputSchema": {"type": "object", "properties": {"ms": {"type": "integer", "default": 1000}}}}
{"name": "wait_for", "description": "Wait for an element to appear on the page.", "inputSchema": {"type": "object", "properties": {"selector": {"type": "string"}, "timeout": {"type": "integer", "default": 10000}}, "required": ["selector"]}}
```
## Implementation
### New file: `src/agent_mcp.h` / `src/agent_mcp.c`
```c
/*
* MCP (Model Context Protocol) server endpoint.
* Implements the Streamable HTTP transport on top of our existing
* SoupServer. Exposes browser tools as MCP tools for AI assistants.
*/
/* Register the MCP handler at /mcp on the given SoupServer. */
void agent_mcp_register(SoupServer *server);
```
### MCP request handling
The MCP handler at `/mcp` processes HTTP POST requests with JSON-RPC
bodies. It handles three methods:
1. **`initialize`** — returns server info and capabilities, creates a session, returns `Mcp-Session-Id` header
2. **`tools/list`** — returns the tool catalog (all 30 tools with schemas)
3. **`tools/call`** — dispatches to `agent_tools_dispatch()` (same as WebSocket)
4. **`ping`** — health check, returns empty result
5. **`resources/list`** / **`resources/templates/list`** — returns empty lists (no resources exposed)
6. **`prompts/list`** — returns empty list (no prompts exposed)
7. **`logging/setLevel`** — acknowledged, no filtering applied
8. **`notifications/*`** — returns 202 Accepted with no body
9. **`GET /mcp`** — returns SSE content-type with `: connected` comment
10. **`DELETE /mcp`** — terminates the session (requires `Mcp-Session-Id` header)
For `tools/call`, the MCP handler:
1. Extracts the tool name and arguments from the JSON-RPC params
2. Constructs a cJSON request object (same format as WebSocket)
3. Calls `agent_tools_dispatch(request, NULL)` — passing NULL for the
connection since MCP uses HTTP response, not WebSocket
4. For sync tools: returns the response immediately as JSON-RPC result
5. For async tools (snapshot, eval, etc.): this is the tricky part...
### Async tool handling for MCP
The async tools (snapshot, eval, get_text, etc.) send their response
through a WebSocket connection callback. But MCP uses HTTP responses,
not WebSocket. We need a way to capture the async result and return it
in the HTTP response.
**Approach:** Use a GMainLoop polling approach (which works for HTTP
since we're not inside a WebSocket callback). The MCP handler:
1. Creates a temporary "result holder" struct
2. Calls `agent_js_eval_async()` with a custom callback that stores
the result in the holder and quits a GMainLoop
3. Runs a GMainLoop with timeout
4. When the JS completes, the callback stores the result
5. The handler returns the result as the HTTP response
This works because the MCP HTTP handler is a regular SoupServer callback
(not a WebSocket message callback), so the GMainLoop polling approach
works correctly.
### Tool catalog generation
The tool catalog is a static cJSON array built once at startup. Each
tool entry has:
- `name`: tool name string
- `description`: human-readable description
- `inputSchema`: JSON Schema object
### Modified files
- **`src/agent_mcp.h` / `src/agent_mcp.c`** — new, MCP handler
- **`src/agent_server.c`** — call `agent_mcp_register()` in `agent_server_start()`
- **`src/agent_tools.c`** — add a variant of dispatch that works with
a result-holder instead of a WebSocket connection (for MCP async tools)
- **`Makefile`** — add `src/agent_mcp.c` to SRC
### Roo Code configuration
To use the browser as an MCP server in Roo Code, add this to the MCP
configuration:
```json
{
"mcpServers": {
"sovereign-browser": {
"url": "http://localhost:17777/mcp",
"transport": "streamable-http"
}
}
}
```
## Implementation order
1. Create `src/agent_mcp.h` / `src/agent_mcp.c` with the tool catalog
2. Implement `initialize` and `tools/list` handlers
3. Implement `tools/call` for sync tools (login, open, tab_list, etc.)
4. Implement `tools/call` for async tools (snapshot, eval, get_text, etc.)
using the GMainLoop polling approach
5. Register the MCP handler in `agent_server_start()`
6. Test with Roo Code or a manual MCP client
7. Update Makefile and docs
## Implementation Status
**Completed** — all items in the implementation order above are done.
### What's implemented
- **MCP Streamable HTTP transport** on `/mcp` (port 17777), coexisting with the existing `/agent` WebSocket endpoint.
- **Session management**: `initialize` creates a session and returns a `Mcp-Session-Id` header. Subsequent requests are validated against the session store. Unknown/expired sessions return 404. Sessions have a 60-second idle timeout.
- **SSE responses**: all JSON-RPC responses (requests with an `id`) are wrapped in `event: message\ndata: <json>\n\n` SSE format with `Content-Type: text/event-stream`.
- **GET /mcp**: returns a `text/event-stream` response with a `: connected` comment (satisfies clients probing for SSE push support).
- **DELETE /mcp**: terminates a session by `Mcp-Session-Id` header. Returns 200 on success, 400 if header missing, 404 if session unknown.
- **Notifications**: `notifications/*` and any request without an `id` return 202 Accepted with no body.
- **Tool catalog**: 30 tools exposed (29 original + `screenshot`). The `screenshot` tool returns an MCP image content block (`type: "image"`, base64 PNG, `mimeType: "image/png"`) instead of a text block.
- **Lenient mode**: requests without a `Mcp-Session-Id` header are accepted with a warning (backward compatibility with older clients).
- **Roo Code integration**: configured in `mcp_settings.json` with `transport: "streamable-http"` and all 30 tools in `alwaysAllow`.
### Test results (2026-07-12)
All 10 integration tests pass:
| Test | Description | Result |
|------|-------------|--------|
| 1 | Initialize — returns `Mcp-Session-Id` header + SSE body | ✅ Pass |
| 2 | `tools/list` — 30 tools including `screenshot` | ✅ Pass |
| 3 | `ping` with session — SSE empty result | ✅ Pass |
| 4 | `resources/list` — empty resources array | ✅ Pass |
| 5 | `prompts/list` — empty prompts array | ✅ Pass |
| 6 | `notifications/initialized` — 202 Accepted | ✅ Pass |
| 7 | `GET /mcp` — `text/event-stream` content type | ✅ Pass |
| 8 | `DELETE /mcp` — 200 OK | ✅ Pass |
| 9 | `ping` after DELETE — 404 (session terminated) | ✅ Pass |
| 10 | `login_status` tool call — returns login state | ✅ Pass |
### Files
- [`src/agent_mcp.c`](src/agent_mcp.c) — MCP handler (request routing, session management, SSE responses)
- [`src/agent_mcp.h`](src/agent_mcp.h) — public API
- [`src/agent_tools.c`](src/agent_tools.c) — tool dispatch (shared with WebSocket endpoint)
- [`src/agent_server.c`](src/agent_server.c) — registers MCP handler on the SoupServer
- [`Makefile`](Makefile) — includes `src/agent_mcp.c` in the build
+290
View File
@@ -0,0 +1,290 @@
# Phase 3 — Extended Tool Catalog (70 tools)
> **Status: Complete** — All 8 batches implemented, built, and verified.
> 100 total tools (30 Phase 1 + 70 Phase 3). See the verification table
> at the bottom and [`plans/agent-tools.md`](agent-tools.md) for the full
> status.
## Overview
Implement all remaining tools from the agent-browser comparison table.
The original plan counted 67 tools; the final implementation delivered 70
(11+7+10+5+11+13+10+3). The difference of 3 comes from expanding the
storage category: the plan listed 8 local-only storage tools, but we
added 4 `storage_session_*` tools (get, get_key, set, clear) for full
session storage parity, bringing the storage batch to 11. These extend
sovere_browser's MCP server from 30 tools to 100 tools, covering the full
agent-browser feature set that has a WebKitGTK equivalent.
## Implementation patterns
All Phase 3 tools follow one of these patterns:
### Pattern A: JS eval (sync)
The majority of tools. Resolve ref/selector → build JS string → `agent_js_eval_sync()` → parse result → return cJSON. Same pattern as existing `click`, `fill`, `get_text`, etc.
### Pattern B: WebKitGTK C API
Some tools need WebKitGTK C APIs instead of JS:
- Cookies → `WebKitCookieManager`
- Settings → `WebKitSettings`
- Dialogs → `WebKitScriptDialog` (via signal handler)
- Console → `WebKitConsoleMessage` (via signal handler)
- PDF → `webkit_web_view_save()` / print API
- State → `WebKitWebsiteDataManager`
### Pattern C: GDK/GTK API
- Clipboard → `GtkClipboard` / `GdkClipboard`
- Mouse events → `gdk_event_new()` + `gtk_main_do_event()` (synthesized)
- Viewport → `gtk_window_resize()`
### Pattern D: Composite
- `batch` — calls `agent_tools_dispatch()` in a loop
- `find_*` — builds CSS selector from semantic criteria, then uses Pattern A
- `wait_for_*` — polls with `agent_js_eval_sync()` in a loop (like existing `wait_for`)
## Batches
### Batch 1: Extended interaction (11 tools)
All Pattern A (JS eval). Straightforward extensions of existing interaction tools.
| Tool | Params | JS approach | Notes |
|------|--------|-------------|-------|
| `dblclick` | ref/selector | `el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))` | Like click but dblclick event |
| `select` | ref/selector, value | Set `<select>` `.value` + dispatch change event | For dropdowns |
| `check` | ref/selector | `el.checked = true; el.dispatchEvent(new Event('change'))` | Checkboxes |
| `uncheck` | ref/selector | `el.checked = false; el.dispatchEvent(new Event('change'))` | Checkboxes |
| `scroll_into_view` | ref/selector | `el.scrollIntoView({behavior:'smooth',block:'center'})` | Scroll element into view |
| `keyboard_type` | value | Synthesize keydown+keypress+keyup per char on focused element | Real keystrokes |
| `insert_text` | value | `document.execCommand('insertText', false, text)` | Insert without key events |
| `keydown` | key | `new KeyboardEvent('keydown',{key:key,bubbles:true})` | Hold key down |
| `keyup` | key | `new KeyboardEvent('keyup',{key:key,bubbles:true})` | Release key |
| `drag` | src_ref/selector, tgt_ref/selector | Synthesize dragstart→dragenter→dragover→drop→dragend | HTML5 drag and drop |
| `close_all` | (none) | Close all tabs via `tab_manager_close_all()` | New tab_manager function needed |
### Batch 2: Get info + check state (7 tools)
All Pattern A (JS eval). Read-only queries returning element state.
| Tool | Params | JS approach | Returns |
|------|--------|-------------|---------|
| `get_value` | ref/selector | `el.value` | `{"value":"..."}` |
| `get_count` | selector | `document.querySelectorAll(sel).length` | `{"count":N}` |
| `get_box` | ref/selector | `el.getBoundingClientRect()` | `{"x":N,"y":N,"width":N,"height":N}` |
| `get_styles` | ref/selector | `getComputedStyle(el)` → JSON | `{"styles":{...}}` |
| `is_visible` | ref/selector | Check offsetParent, computed display/visibility | `{"visible":true/false}` |
| `is_enabled` | ref/selector | `!el.disabled` | `{"enabled":true/false}` |
| `is_checked` | ref/selector | `el.checked` | `{"checked":true/false}` |
### Batch 3: Find elements (10 tools)
Pattern D (composite). Build CSS selector from semantic criteria, then query.
| Tool | Params | Selector approach |
|------|--------|-------------------|
| `find_role` | role, name? | `[role="X"]` or semantic HTML (button, nav, etc.) |
| `find_text` | text, exact? | `:contains()` via JS (no native CSS) — walk DOM |
| `find_label` | label | `label[for]` matching + `aria-label` + `aria-labelledby` |
| `find_placeholder` | placeholder | `[placeholder*="X"]` |
| `find_alt` | alt | `[alt*="X"]` |
| `find_title` | title | `[title*="X"]` |
| `find_testid` | testid | `[data-testid="X"]` |
| `find_first` | selector | `document.querySelector(sel)` |
| `find_last` | selector | `document.querySelectorAll(sel)[last]` |
| `find_nth` | n, selector | `document.querySelectorAll(sel)[n]` |
All return `{"ref":"@eN","selector":"...","role":"...","name":"..."}` — assigns a ref so the result can be used with click/fill/etc. This requires integration with the snapshot ref system (`window.__agentRefs`).
### Batch 4: Wait + batch (5 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `wait_for_text` | text, timeout? | Poll JS: `document.body.innerText.includes(text)` | D (poll) |
| `wait_for_url` | url_pattern, timeout? | Poll `webkit_web_view_get_uri()` against regex | D (poll) |
| `wait_for_load` | state?, timeout? | Wait for WebKitWebView load event | B (signal) |
| `wait_for_fn` | script, timeout? | Poll JS: eval user script, check truthy | D (poll) |
| `batch` | commands[] | Loop: call `agent_tools_dispatch()` per command | D (composite) |
### Batch 5: Cookies + storage (11 tools)
Pattern B (WebKit API) for cookies, Pattern A (JS eval) for storage.
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `cookies_get` | (none) | `WebKitCookieManager` async API | B |
| `cookies_set` | name, value, domain, path, ... | JS: `document.cookie = "..."` | A |
| `cookies_clear` | (none) | `webkit_cookie_manager_clear()` | B |
| `storage_local_get` | (none) | JS: `JSON.stringify(localStorage)` | A |
| `storage_local_get_key` | key | JS: `localStorage.getItem(key)` | A |
| `storage_local_set` | key, value | JS: `localStorage.setItem(key, value)` | A |
| `storage_local_clear` | (none) | JS: `localStorage.clear()` | A |
| `storage_session_get` | (none) | JS: `JSON.stringify(sessionStorage)` | A |
| `storage_session_get_key` | key | JS: `sessionStorage.getItem(key)` | A |
| `storage_session_set` | key, value | JS: `sessionStorage.setItem(key, value)` | A |
| `storage_session_clear` | (none) | JS: `sessionStorage.clear()` | A |
Note: The original plan collapsed `storage_session_*` into a single row
and counted 8 total for the batch (3 cookies + 4 local + 1 session row).
The implementation expanded the session row into 4 concrete tools, giving
11 total (3 cookies + 4 local + 4 session). This is the source of the
+3 difference between the planned 67 and the implemented 70.
### Batch 6: Mouse + clipboard + settings (12 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `mouse_move` | x, y | JS: dispatch mousemove at coordinates | A |
| `mouse_down` | button? | JS: dispatch mousedown | A |
| `mouse_up` | button? | JS: dispatch mouseup | A |
| `mouse_wheel` | dy, dx? | JS: dispatch wheel event | A |
| `clipboard_read` | (none) | `gdk_clipboard_read_text_async()` | C |
| `clipboard_write` | text | `gdk_clipboard_set_text()` | C |
| `clipboard_copy` | (none) | JS: `document.execCommand('copy')` | A |
| `clipboard_paste` | (none) | JS: `document.execCommand('paste')` | A |
| `set_viewport` | width, height | `gtk_window_resize()` | C |
| `set_offline` | on/off | `WebKitWebContext` network policy | B |
| `set_headers` | headers_json | `WebKitUserContentManager` injection or custom | B |
| `set_credentials` | user, pass | `WebKitAuthenticationRequest` handler | B |
| `set_media` | dark/light | `WebKitSettings` + CSS media query emulation | B |
Note: set_media is 1 tool, set_credentials is 1 tool. Total for this batch: 13 tools (mouse 4 + clipboard 4 + settings 5).
### Batch 7: Frames + dialogs + debug (10 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `frame_switch` | ref/selector | Track current frame in C, eval JS in frame context | B |
| `frame_main` | (none) | Reset to main frame | B |
| `dialog_accept` | text? | `webkit_script_dialog_confirm_set_confirmed()` etc. | B |
| `dialog_dismiss` | (none) | Same API, set false | B |
| `dialog_status` | (none) | Check if a script dialog is pending | B |
| `console` | (none) | Collect console messages (need signal handler) | B |
| `errors` | (none) | Collect page errors (need signal handler) | B |
| `highlight` | ref/selector | JS: add temporary outline/border | A |
| `state_save` | (none) | `WebKitWebsiteDataManager` persist | B |
| `state_load` | (none) | Restore from persisted data | B |
Note: state_save/load/list = 3 tools. Total: 10 tools (frames 2 + dialogs 3 + debug 2 + highlight 1 + state 2).
### Batch 8: Complex tools (3 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `upload` | ref/selector, files[] | Set file input `.files` via JS (limited) or WebKit API | B/A |
| `pdf` | path? | `webkit_web_view_save()` or print-to-PDF API | B |
| `screenshot_annotated` | (none) | Screenshot + overlay element labels via JS canvas | A+B |
## Tool count verification
| Batch | Tools | Count | Status |
|-------|-------|-------|--------|
| 1 | Extended interaction | 11 | ✅ Complete |
| 2 | Get info + check state | 7 | ✅ Complete |
| 3 | Find elements | 10 | ✅ Complete |
| 4 | Wait + batch | 5 | ✅ Complete |
| 5 | Cookies + storage | 11 | ✅ Complete (was 8 in plan; +3 `storage_session_*`) |
| 6 | Mouse + clipboard + settings | 13 | ✅ Complete |
| 7 | Frames + dialogs + debug | 10 | ✅ Complete |
| 8 | Complex | 3 | ✅ Complete |
| **Total** | | **70** | ✅ All batches complete |
### Final verification (post-implementation)
- `make clean && make` — builds cleanly (only pre-existing warnings in
`login_dialog.c` and `agent_mcp.c`).
- `tools/list` returns exactly **100 tools** (30 Phase 1 + 70 Phase 3).
- `initialize` returns a session ID; `ping` returns an empty result.
- `login_status` works without login (`logged_in: false`).
- Browser tools (e.g. `dblclick`) return `NOT_LOGGED_IN` before login.
- `batch` dispatches without login; sub-commands enforce login
individually (bug fix: `batch` was incorrectly gated by login — added
to the login exception list in `agent_tools_dispatch()`).
- Roo Code MCP config `alwaysAllow` updated with all 100 tool names.
## Implementation order
Batches 1-4 are all Pattern A (JS eval) or simple composites — no new WebKit API knowledge needed. They should be done first.
Batches 5-8 require WebKitGTK C API knowledge and are more complex. They should be done after the JS-based tools are stable.
### Recommended execution order
1. **Batch 1** (11 tools) — extended interaction, all JS eval
2. **Batch 2** (7 tools) — get info + check state, all JS eval
3. **Batch 3** (10 tools) — find elements, JS eval + ref integration
4. **Batch 4** (5 tools) — wait + batch, polling + composite
5. **Batch 5** (11 tools) — cookies + storage, mixed WebKit API + JS
6. **Batch 6** (13 tools) — mouse + clipboard + settings, mixed
7. **Batch 7** (10 tools) — frames + dialogs + debug, WebKit API
8. **Batch 8** (3 tools) — complex tools, WebKit API
## Files to modify
- `src/agent_tools.c` — all tool implementations + dispatch table entries
- `src/agent_mcp.c` — tool catalog entries (`tool_defs[]`) + schemas
- `src/agent_tools.h` — no changes needed (dispatch API unchanged)
- `src/tab_manager.c` / `src/tab_manager.h` — add `tab_manager_close_all()` for `close_all`
- `src/agent_server.c` — may need console/error message signal handlers
- `~/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json` — add new tools to `alwaysAllow`
- `plans/agent-tools.md` — update Phase 3 status
## Key design decisions
### Find tools and ref integration
The `find_*` tools need to assign refs so results work with `click @eN` etc. This means they must register the found element in `window.__agentRefs` (the same mechanism the snapshot uses). The find tool will:
1. Run JS that finds the element and generates a unique CSS selector for it
2. Assign a new ref (incrementing counter from `window.__agentRefCounter`)
3. Store `{selector: "...", role: "...", name: "..."}` in `window.__agentRefs[eN]`
4. Return the ref to the caller
### Batch tool
The `batch` tool takes an array of tool requests and executes them sequentially:
```json
{"tool":"batch","params":{"commands":[
{"tool":"open","params":{"url":"https://example.com"}},
{"tool":"snapshot","params":{}},
{"tool":"click","params":{"ref":"@e1"}}
]}}
```
Returns an array of responses. Each command is dispatched via `agent_tools_dispatch()`. If a command fails, the batch stops (unless `continueOnError: true`).
### Console and error collection
Need to connect to WebKitWebView's `console-message` signal and `notify::title` or `load-failed` signals. Store messages in a per-webview GArray or GPtrArray. The `console` and `errors` tools return the collected messages and optionally clear them.
### Dialog handling
WebKitGTK emits `script-dialog` signal for alert/confirm/prompt. We need to:
1. Connect to the signal
2. Store the pending dialog (type, message, default text)
3. The `dialog_accept`/`dialog_dismiss` tools call the appropriate `webkit_script_dialog_*_set_*()` function
4. `dialog_status` returns whether a dialog is pending and its details
### Frame switching
WebKitGTK doesn't have a direct "switch to frame" API like Selenium. We'll track a "current frame" selector in C state. All JS eval tools will wrap their scripts to execute within the frame context:
```js
(function() {
var frame = document.querySelector('<frame_selector>');
var doc = frame ? frame.contentDocument : document;
// ... run tool JS within doc ...
})();
```
`frame_main` resets the frame selector to NULL (use main document).
## Testing
After each batch:
1. `make clean && make` — verify build
2. Start browser, test each new tool with `curl` against `/mcp`
3. Verify `tools/list` shows the new tools
4. Update `mcp_settings.json` `alwaysAllow` array
## Mermaid: Implementation flow
```mermaid
flowchart TB
Start[Phase 3 Start] --> B1[Batch 1: Extended Interaction<br/>11 tools, JS eval]
B1 --> B2[Batch 2: Get Info + Check State<br/>7 tools, JS eval]
B2 --> B3[Batch 3: Find Elements<br/>10 tools, JS eval + refs]
B3 --> B4[Batch 4: Wait + Batch<br/>5 tools, polling + composite]
B4 --> B5[Batch 5: Cookies + Storage<br/>11 tools, WebKit API + JS]
B5 --> B6[Batch 6: Mouse + Clipboard + Settings<br/>13 tools, mixed]
B6 --> B7[Batch 7: Frames + Dialogs + Debug<br/>10 tools, WebKit API]
B7 --> B8[Batch 8: Complex<br/>3 tools, WebKit API]
B8 --> Final[Final: Build, Test, Update Config + Docs]
Final --> Done[100 tools total<br/>30 Phase 1 + 70 Phase 3]
```
+127
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
+167
View File
@@ -0,0 +1,167 @@
# Plan: Migrate All Storage to SQLite + Remove Key Persistence
## Overview
Consolidate all browser data storage into the SQLite database (`~/.sovereign_browser/browser.db`) and **remove all key persistence** — private keys live only in RAM and are gone when the browser quits.
## Current state
| File | Format | Stores | Action |
|------|--------|--------|--------|
| `identity.json` | JSON | Nostr private key, mnemonic, bunker URL | **Delete the code + file** — keys must never touch disk |
| `history.txt` | Plain text | Recent URLs (max 50) | **Migrate to SQLite** `history` table |
| `session.txt` | Plain text | Open tab URLs | **Migrate to SQLite** `session` table |
| `settings.conf` | key=value | Browser preferences | **Migrate to SQLite** `key_value` table (already exists) |
| `browser.db` | SQLite | Nostr events, tags, key_value | **Keep + extend** |
## Phase 1: Remove key persistence
### `src/key_store.h` / `src/key_store.c`
**Remove:**
- `key_store_path()` — no file path needed
- `key_store_save()` — never called (dead code)
- `key_store_load()` — never called (dead code)
- `key_store_clear()` — replace with a no-op or remove the calls
**Keep:**
- `key_store_identity_t` struct (in-memory only)
- `key_store_method_t` enum
- `key_store_create_signer()` — creates a signer from an in-memory identity
### `src/cli.h` / `src/cli.c`
**Remove:**
- `--no-save-identity` flag and `no_save_identity` field (meaningless now)
- The TODO comment about `key_store_save()`
### `src/main.c` / `src/agent_login.c`
**Remove:**
- `key_store_clear()` calls in `app_menu_logout_proxy()` and `agent_login.c` logout
### Defensive cleanup
On startup, delete `~/.sovereign_browser/identity.json` if it exists (in case a previous version created it).
## Phase 2: Migrate history to SQLite
### `src/db.h` / `src/db.c`
Add a `history` table to the schema:
```sql
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT,
visited_at INTEGER NOT NULL,
visit_count INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_history_visited_at ON history(visited_at DESC);
```
Add functions:
```c
int db_history_add(const char *url, const char *title);
/* Returns most-recent-first. Fills urls_out (caller frees each + array). */
char **db_history_get(int *count_out, int limit);
int db_history_clear(void);
```
`db_history_add` does an UPSERT: on conflict (URL already exists), increment `visit_count` and update `visited_at`.
### `src/history.h` / `src/history.c`
Rewrite to use `db_history_add` / `db_history_get` / `db_history_clear`. Remove the flat-file `history_path()`, `fopen()`, the `g_history[50][2048]` array, and `history_load()`. The `history_add()` function now takes an optional title parameter (or we keep the existing signature and pass NULL for title from `on_load_changed`).
Remove `HISTORY_MAX_ENTRIES` (no cap — SQLite handles it).
## Phase 3: Migrate session to SQLite
### `src/db.h` / `src/db.c`
Add a `session` table:
```sql
CREATE TABLE IF NOT EXISTS session (
tab_index INTEGER PRIMARY KEY,
url TEXT NOT NULL,
title TEXT
);
```
Add functions:
```c
int db_session_save(const char **urls, const char **titles, int count);
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
int db_session_clear(void);
```
`db_session_save` clears the table then inserts all current tabs. `db_session_load` reads them back in tab_index order.
### `src/session.h` / `src/session.c`
Rewrite `session_save()` and `session_restore()` to use the SQLite functions. Remove `session_path()` and `fopen()`.
## Phase 4: Migrate settings to SQLite
### `src/settings.h` / `src/settings.c`
Rewrite `settings_load()` and `settings_save()` to use `db_kv_get` / `db_kv_set` for each field. The `key_value` table already exists in the schema.
Each setting is stored as a key-value pair:
- `restore_session``"true"` / `"false"`
- `new_tab_url` → the URL string
- `tab_bar_position``"top"` / `"bottom"` / etc.
- `bootstrap_relays` → newline-separated URLs
- etc.
**Important:** `settings_load()` must be called **after** `db_init()` (the DB must be open first). This changes the startup order in `main.c`:
```
settings_load() → db_init() → settings_load() (revised order)
```
Actually: `db_init()` first, then `settings_load()` reads from the DB.
### Remove `settings_path()` and `fopen()` from `settings.c`.
## Phase 5: Cleanup
- Delete `~/.sovereign_browser/identity.json` on startup (defensive)
- Delete `~/.sovereign_browser/history.txt` on startup (one-time migration)
- Delete `~/.sovereign_browser/session.txt` on startup (one-time migration)
- Delete `~/.sovereign_browser/settings.conf` on startup (one-time migration)
- Or: leave the old files in place (they're just ignored) — simpler, less destructive
## Startup order change in `main.c`
Current:
```
settings_load();
history_load();
db_init();
```
New:
```
db_init(); /* open the database first */
settings_load(); /* reads from key_value table */
/* history_load() removed — history is queried from SQLite on demand */
```
## File change summary
| File | Change |
|------|--------|
| `src/key_store.h` | Remove `key_store_save`, `key_store_load`, `key_store_clear`, `key_store_path` |
| `src/key_store.c` | Remove file I/O functions; keep only `key_store_create_signer` |
| `src/db.h` | Add `db_history_*`, `db_session_*` functions |
| `src/db.c` | Add `history` + `session` tables to schema; implement new functions |
| `src/history.h` | Update API (remove `history_load`, add title param) |
| `src/history.c` | Rewrite to use SQLite; remove flat-file code |
| `src/session.h` | No API change |
| `src/session.c` | Rewrite to use SQLite; remove flat-file code |
| `src/settings.h` | No API change |
| `src/settings.c` | Rewrite to use `db_kv_get`/`db_kv_set`; remove flat-file code |
| `src/main.c` | Reorder startup (`db_init` before `settings_load`); remove `key_store_clear` call; delete old files defensively |
| `src/agent_login.c` | Remove `key_store_clear` call |
| `src/cli.h` | Remove `no_save_identity` field |
| `src/cli.c` | Remove `--no-save-identity` flag |
+299
View File
@@ -0,0 +1,299 @@
# Plan: SQLite Integration, No-Login Mode, Bootstrap Relay Fetch
## Overview
Three related features that build on each other:
1. **SQLite** — persistent local storage for Nostr events and future data
2. **No-Login mode** — bypass Nostr login, use the browser as a normal browser
3. **Bootstrap relay fetch** — after login, fetch the user's kind 0/3/10002 events from configured relays and cache them in SQLite
```mermaid
flowchart TD
A[Browser startup] --> B{Login dialog}
B -->|Sign In| C[Login with Nostr key]
B -->|No Login| D[Skip Nostr — normal browser mode]
B -->|Cancel| E[Exit]
C --> F[Fetch kind 0, 3, 10002 from bootstrap relays]
F --> G[Store events in SQLite]
G --> H[Browser ready with identity + cached events]
D --> I[Browser ready — no identity, no relay fetch]
subgraph "SQLite database"
J[events table]
K[event_tags table]
L[relays table]
M[key_value table]
end
G --> J
G --> K
H --> M
```
---
## Phase 1: SQLite Integration
### Install dependency
```bash
sudo apt install libsqlite3-dev
```
This provides `sqlite3.h`, `libsqlite3.so`, and `sqlite3.pc` (pkg-config).
### New files: `src/db.h` / `src/db.c`
A thin wrapper around SQLite3 that provides:
```c
/* db.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
/* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables if they don't exist. Call once at startup. */
int db_init(void);
/* Close the database. Call at shutdown. */
void db_close(void);
/* ── Events ─────────────────────────────────────────────── */
/* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events table + tags table. */
int db_store_event(const cJSON *event);
/* Fetch the most recent event of a given kind for a pubkey.
* Returns a newly allocated cJSON event, or NULL if not found.
* Caller must cJSON_Delete() the result. */
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/* Fetch all events of a given kind for a pubkey (newest first).
* Returns a cJSON array. Caller must cJSON_Delete() the result. */
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
/* ── Key-Value store (for misc settings/cache) ──────────── */
int db_kv_set(const char *key, const char *value);
const char *db_kv_get(const char *key); /* returns pointer, valid until next db_kv_get call */
```
### Database schema
```sql
-- Nostr events (kind 0 = profile, 3 = contacts, 10002 = relay list, etc.)
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, -- event ID (hex, 64 chars)
pubkey TEXT NOT NULL, -- author pubkey (hex)
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL, -- unix timestamp
content TEXT, -- event content
sig TEXT, -- signature
raw_json TEXT NOT NULL, -- full event JSON for round-tripping
fetched_at INTEGER NOT NULL -- when we cached it
);
-- Event tags (for querying by tag values, e.g. relay URLs in kind 10002)
CREATE TABLE IF NOT EXISTS event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL, -- first element of tag array
tag_value TEXT, -- second element (if any)
position INTEGER NOT NULL, -- tag index in the event
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_tags_name_value ON event_tags(tag_name, tag_value);
-- Simple key-value store for misc data
CREATE TABLE IF NOT EXISTS key_value (
key TEXT PRIMARY KEY,
value TEXT
);
```
### Makefile changes
```make
CFLAGS += $(shell pkg-config --cflags sqlite3)
LDLIBS += $(shell pkg-config --libs sqlite3)
SRC += src/db.c
```
### Integration in `src/main.c`
- Call `db_init()` after `settings_load()` in `main()`.
- Call `db_close()` in `on_window_destroy()` before `gtk_main_quit()`.
---
## Phase 2: No-Login Mode
### Login dialog changes (`src/login_dialog.c`)
**Button layout** (action area, left to right):
1. `No Login` — far left (NEW)
2. `Cancel` — center
3. `Sign In` — right
**Changes:**
- Remove underscores from button labels: `"_Cancel"``"Cancel"`, `"_Sign In"``"Sign In"`.
- Add a third button: `GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");`
- Pack it first (far left): `gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);` before cancel and login buttons.
- Wire a new `on_no_login_clicked` handler that sets `ctx->done = TRUE` and responds with a new response code (e.g. `GTK_RESPONSE_OTHER` or a custom value like `GTK_RESPONSE_APPLY`).
**`login_result_t` changes (`src/login_dialog.h`):**
- The existing `method == KEY_STORE_METHOD_NONE` with `signer == NULL` already represents "no identity." The dialog will return success (0) with `method = KEY_STORE_METHOD_NONE` and empty `pubkey_hex`.
**`login_dialog_run` changes:**
- After `gtk_dialog_run` returns, check for the no-login response code. If it's the no-login response, set `result->method = KEY_STORE_METHOD_NONE`, clear the result, and return 0 (success).
### `src/main.c` changes
- In `do_login()`, after a successful login with `method == KEY_STORE_METHOD_NONE` and empty pubkey, set `g_state.readonly = TRUE` and `g_logged_in = TRUE` but don't set a signer. The browser works normally — `window.nostr` won't be available (the bridge will return "No identity loaded" for sign requests), but all normal browsing works.
- Skip the relay fetch (Phase 4) when no identity is loaded.
### CLI support (`src/cli.c` / `src/cli.h`)
- Add `--no-login` flag that skips the login dialog entirely (sets `g_logged_in = TRUE` with no signer). This is equivalent to clicking "No Login" but from the command line.
---
## Phase 3: Bootstrap Relays in Settings
### Settings struct (`src/settings.h` / `src/settings.c`)
Add a new field to `browser_settings_t`:
```c
#define SETTINGS_BOOTSTRAP_RELAYS_MAX 2048
// ...
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
```
Default value in `settings_set_defaults()`:
```
wss://laantungir.net/relay\nwss://relay.primal.net\nwss://relay.damus.io
```
Parse/save in `settings_load()` / `settings_save()` as `bootstrap_relays=...` (URL-encoded or newline-separated, stored as a single key=value line).
### Settings page (`src/nostr_bridge.c`)
Add a new section **"Bootstrap Relays"** to the `sovereign://settings` page (between Agent Server and Security):
```html
<h2>Bootstrap Relays</h2>
<p class='note'>Relays queried after login to fetch your profile (kind 0),
contacts (kind 3), and relay list (kind 10002). One URL per line.</p>
<div class='field'>
<div><div class='setting-name'>Relay URLs</div>
<div class='setting-desc'>One wss:// URL per line</div></div>
<div><textarea id='bootstrap_relays' rows='4' cols='40'>...</textarea>
<button class='save-btn' onclick="save('bootstrap_relays')">Save</button></div>
</div>
```
The `handle_settings_set` key/value path needs a new case for `bootstrap_relays` that stores the newline-separated value into `bs->bootstrap_relays` and calls `settings_save()`.
---
## Phase 4: Post-Login Relay Fetch
### New file: `src/relay_fetch.h` / `src/relay_fetch.c`
```c
/* relay_fetch.h */
#pragma once
#include "../nostr_core_lib/cjson/cJSON.h"
/* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays.
* Stores results in the SQLite database via db_store_event().
* Runs synchronously (called from a background thread or with a timeout).
*
* pubkey_hex — user's hex pubkey
* relay_urls — NULL-terminated array of relay URLs
* relay_count — number of relays
*
* Returns the number of events fetched and stored, or -1 on error. */
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count);
```
### Implementation (`src/relay_fetch.c`)
Uses `synchronous_query_relays_with_progress()` from `nostr_core_lib`:
1. Parse `bootstrap_relays` from settings into an array of URLs.
2. Build a cJSON filter: `{"authors": [pubkey], "kinds": [0, 3, 10002]}`.
3. Call `synchronous_query_relays_with_progress(relay_urls, relay_count, filter, RELAY_QUERY_ALL_RESULTS, &result_count, 15, NULL, NULL, 0)`.
4. For each returned event, call `db_store_event(event)`.
5. Log the results: `[relay] Fetched N events from M relays`.
6. Free the results array and filter.
### Integration in `src/main.c`
After successful login (in `do_login()` or right after it returns), if a signer/pubkey is available:
```c
if (g_state.pubkey_hex[0] != '\0') {
/* Parse bootstrap relays from settings. */
/* Call relay_fetch_bootstrap() in a background thread to avoid
* blocking the UI. Use g_thread_new() or a GTask. */
g_thread_new("relay-fetch", relay_fetch_thread, NULL);
}
```
The fetch runs in a background thread so the browser is usable immediately. Events are stored in SQLite as they arrive. A future enhancement could notify the UI when the fetch completes.
### Threading consideration
`nostr_core_lib`'s `synchronous_query_relays_with_progress` is a blocking call that uses its own WebSocket event loop. It should be safe to call from a background thread as long as we don't touch GTK widgets from that thread. The `db_store_event()` calls only touch SQLite (which is thread-safe with proper locking — we'll use `sqlite3_mutex` or open the DB with `SQLITE_OPEN_FULLMUTEX`).
---
## Phase 5: Build, Verify, Test
1. `sudo apt install libsqlite3-dev`
2. `make` — verify clean build
3. Test No-Login mode:
- `./browser.sh start --no-login`
- Verify browser opens without login dialog, normal browsing works
- Verify `window.nostr` returns "No identity loaded" errors
4. Test login + relay fetch:
- `./browser.sh start --login-method generate`
- Verify log shows `[relay] Fetched N events from M relays`
- Verify `~/.sovereign_browser/browser.db` contains events
5. Test settings page:
- Navigate to `sovereign://settings`
- Verify "Bootstrap Relays" section appears with the 3 default relays
- Edit relays, save, restart, verify persistence
6. Test login dialog buttons:
- Verify "No Login" button is far left
- Verify "Cancel" and "Sign In" labels have no underscores
---
## File change summary
| File | Change |
|------|--------|
| `src/db.h` | NEW — SQLite wrapper API |
| `src/db.c` | NEW — SQLite implementation (events, tags, key-value) |
| `src/relay_fetch.h` | NEW — relay fetch API |
| `src/relay_fetch.c` | NEW — fetch kind 0/3/10002 from bootstrap relays |
| `src/settings.h` | Add `bootstrap_relays` field + max constant |
| `src/settings.c` | Parse/save `bootstrap_relays` with defaults |
| `src/nostr_bridge.c` | Add "Bootstrap Relays" section to settings page + set handler |
| `src/login_dialog.c` | Add "No Login" button, remove underscores, new handler |
| `src/login_dialog.h` | Document no-login result (method=NONE, empty pubkey) |
| `src/main.c` | Call `db_init()`/`db_close()`, handle no-login result, trigger relay fetch |
| `src/cli.h` | Add `--no-login` flag |
| `src/cli.c` | Parse `--no-login`, skip login dialog |
| `Makefile` | Add `sqlite3` to pkg-config, add `src/db.c` + `src/relay_fetch.c` to SRC |
+482
View File
@@ -0,0 +1,482 @@
/*
* agent_login.c — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly to authenticate, bypassing the GTK
* login dialog. The agent provides credentials as JSON params and
* gets back the pubkey/npub on success.
*/
#include "agent_login.h"
#include "key_store.h"
#include "nostr_bridge.h"
#include "nostr_core/nostr_core.h"
#include <string.h>
#include <stdlib.h>
#include <signal.h>
/* ── Global login state (shared with main.c) ──────────────────────── *
* main.c owns the app_state_t (signer, pubkey, method, readonly).
* We access it via extern functions that main.c provides.
*/
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
key_store_method_t method, gboolean readonly);
extern void app_clear_signer(void);
extern nostr_signer_t *app_get_signer(void);
extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* Track whether the agent (not the GTK dialog) performed the login. */
static gboolean g_agent_performed_login = FALSE;
/* ── Helper functions (same logic as login_dialog.c) ──────────────── */
static int derive_pubkey_hex(const unsigned char privkey[32], char pubkey_hex[65]) {
unsigned char pubkey[32];
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) return -1;
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
return 0;
}
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
if (strlen(hex) != 64) return -1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(hex + i * 2, "%02x", &byte) != 1) return -1;
out[i] = (unsigned char)byte;
}
return 0;
}
static void pubkey_hex_to_npub(const char pubkey_hex[65], char npub[128]) {
unsigned char pubkey[32];
for (int i = 0; i < 32; i++) {
unsigned int b;
sscanf(pubkey_hex + i * 2, "%02x", &b);
pubkey[i] = (unsigned char)b;
}
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
npub[0] = '\0';
}
}
/* ── JSON response helpers ────────────────────────────────────────── */
static cJSON *make_success(cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
if (data) {
cJSON_AddItemToObject(resp, "data", data);
}
return resp;
}
static cJSON *make_error(const char *code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static cJSON *make_login_data(const char *method_str, const char *pubkey_hex,
gboolean readonly) {
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", pubkey_hex);
char npub[128];
pubkey_hex_to_npub(pubkey_hex, npub);
cJSON_AddStringToObject(data, "npub", npub);
cJSON_AddBoolToObject(data, "readonly", readonly);
return data;
}
/* ── Login method implementations ─────────────────────────────────── */
static cJSON *login_local(cJSON *params) {
const char *nsec = cJSON_GetStringValue(cJSON_GetObjectItem(params, "nsec"));
const char *privkey_hex = cJSON_GetStringValue(cJSON_GetObjectItem(params, "privkey_hex"));
unsigned char privkey[32];
if (nsec && nsec[0]) {
if (strncmp(nsec, "nsec1", 5) == 0) {
if (nostr_decode_nsec(nsec, privkey) != 0) {
return make_error("INVALID_NSEC", "Failed to decode nsec string");
}
} else if (strlen(nsec) == 64) {
if (hex_to_bytes32(nsec, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("INVALID_INPUT", "Enter an nsec1... or 64-char hex key");
}
} else if (privkey_hex && privkey_hex[0]) {
if (hex_to_bytes32(privkey_hex, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("MISSING_PARAM", "Provide 'nsec' or 'privkey_hex'");
}
char pubkey_hex[65];
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
return make_error("DERIVE_FAILED", "Failed to derive public key");
}
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("local", pubkey_hex, FALSE));
}
/* Generate a fresh Nostr keypair and log in with it.
* This is the quickest way for an agent to get past the login screen
* when it doesn't have a specific identity to use. The generated
* nsec and pubkey are returned in the response so the agent can
* save them if needed. */
static cJSON *login_generate(cJSON *params) {
(void)params; /* no parameters needed */
unsigned char privkey[32], pubkey[32];
if (nostr_generate_keypair(privkey, pubkey) != 0) {
return make_error("KEYGEN_FAILED", "Failed to generate keypair");
}
/* Derive pubkey hex. */
char pubkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
/* Encode private key as nsec for the response. */
char nsec[128];
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
nsec[0] = '\0';
}
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
/* Include the nsec in the response so the agent can save it. */
cJSON *data = make_login_data("generate", pubkey_hex, FALSE);
cJSON_AddStringToObject(data, "nsec", nsec);
return make_success(data);
}
static cJSON *login_seed(cJSON *params) {
const char *mnemonic = cJSON_GetStringValue(cJSON_GetObjectItem(params, "mnemonic"));
cJSON *account_json = cJSON_GetObjectItem(params, "account");
int account = (account_json && cJSON_IsNumber(account_json))
? account_json->valueint : 0;
if (!mnemonic || !mnemonic[0]) {
return make_error("MISSING_PARAM", "Provide 'mnemonic' (12 or 24 BIP-39 words)");
}
unsigned char privkey[32], pubkey[32];
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
return make_error("INVALID_MNEMONIC", "Invalid seed phrase. Check the words and try again.");
}
char pubkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("seed", pubkey_hex, FALSE));
}
static cJSON *login_readonly(cJSON *params) {
const char *npub = cJSON_GetStringValue(cJSON_GetObjectItem(params, "npub"));
const char *pubkey_hex_in = cJSON_GetStringValue(cJSON_GetObjectItem(params, "pubkey_hex"));
unsigned char pubkey[32];
char pubkey_hex[65];
if (npub && npub[0]) {
if (strncmp(npub, "npub1", 5) == 0) {
if (nostr_decode_npub(npub, pubkey) != 0) {
return make_error("INVALID_NPUB", "Failed to decode npub string");
}
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
} else if (strlen(npub) == 64) {
if (hex_to_bytes32(npub, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, npub, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("INVALID_INPUT", "Enter an npub1... or 64-char hex pubkey");
}
} else if (pubkey_hex_in && pubkey_hex_in[0]) {
if (hex_to_bytes32(pubkey_hex_in, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, pubkey_hex_in, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* Read-only: no signer created. */
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("readonly", pubkey_hex, TRUE));
}
static cJSON *login_nip46(cJSON *params) {
const char *bunker_url = cJSON_GetStringValue(cJSON_GetObjectItem(params, "bunker_url"));
if (!bunker_url || !bunker_url[0]) {
return make_error("MISSING_PARAM", "Provide 'bunker_url' (bunker://<pubkey>?relay=...&secret=...)");
}
nostr_nip46_bunker_url_t bunker;
memset(&bunker, 0, sizeof(bunker));
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
return make_error("INVALID_BUNKER", "Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
}
/* Generate a client keypair for NIP-46 encryption. */
unsigned char client_privkey[32], client_pubkey[32];
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
return make_error("KEYGEN_FAILED", "Failed to generate client keypair");
}
char pubkey_hex[65];
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(client_privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("nip46", pubkey_hex, FALSE));
}
static cJSON *login_nsigner(cJSON *params) {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
const char *transport = cJSON_GetStringValue(cJSON_GetObjectItem(params, "transport"));
const char *device = cJSON_GetStringValue(cJSON_GetObjectItem(params, "device"));
const char *service = cJSON_GetStringValue(cJSON_GetObjectItem(params, "service"));
cJSON *index_json = cJSON_GetObjectItem(params, "index");
int index = (index_json && cJSON_IsNumber(index_json)) ? index_json->valueint : 0;
if (!transport || !transport[0]) {
return make_error("MISSING_PARAM", "Provide 'transport' (serial/unix/tcp/qrexec)");
}
if (!device || !device[0]) {
return make_error("MISSING_PARAM", "Provide 'device' (path, socket name, host:port, or qube name)");
}
/* Block SIGCHLD during n_signer calls (qrexec spawns children). */
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGCHLD);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
nostr_signer_t *signer = NULL;
const char *transport_name = "unknown";
if (strcmp(transport, "serial") == 0) {
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
transport_name = "serial";
} else if (strcmp(transport, "unix") == 0) {
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
transport_name = "unix";
} else if (strcmp(transport, "tcp") == 0) {
char host[256] = {0};
int port = 7777;
const char *sep = strchr(device, ':');
if (sep) {
size_t hlen = sep - device;
if (hlen < sizeof(host)) {
memcpy(host, device, hlen);
host[hlen] = '\0';
port = atoi(sep + 1);
}
} else {
strncpy(host, device, sizeof(host) - 1);
}
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
transport_name = "tcp";
} else if (strcmp(transport, "qrexec") == 0) {
const char *svc = (service && service[0]) ? service : "qubes.NsignerRpc";
signer = nostr_signer_nsigner_qrexec(device, svc, NULL, 30000);
transport_name = "qrexec";
} else {
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("INVALID_TRANSPORT", "Unknown transport. Use: serial, unix, tcp, or qrexec");
}
if (signer == NULL) {
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
}
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_INDEX", "Failed to set nostr_index on n_signer.");
}
char pubkey_hex[65];
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
transport_name, index, pubkey_hex);
return make_success(make_login_data("nsigner", pubkey_hex, FALSE));
#else
(void)params;
return make_error("NOT_COMPILED", "n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT)");
#endif
}
/* ── Public API ───────────────────────────────────────────────────── */
cJSON *agent_login_status(void) {
gboolean logged_in = agent_is_logged_in();
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "logged_in", logged_in);
if (logged_in) {
const char *method_str = "none";
switch (app_get_method()) {
case KEY_STORE_METHOD_LOCAL: method_str = "local"; break;
case KEY_STORE_METHOD_SEED: method_str = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_str = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_str = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_str = "nsigner"; break;
default: break;
}
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", app_get_pubkey_hex());
cJSON_AddBoolToObject(data, "readonly", app_get_readonly());
}
return make_success(data);
}
cJSON *agent_login(cJSON *params) {
if (params == NULL) {
return make_error("MISSING_PARAMS", "No parameters provided");
}
if (agent_is_logged_in()) {
return make_error("ALREADY_LOGGED_IN", "Already logged in. Use switch_identity to change.");
}
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(params, "method"));
if (!method || !method[0]) {
return make_error("MISSING_METHOD", "Provide 'method' (local/seed/readonly/nip46/nsigner/generate)");
}
/* Initialize nostr_core_lib if not already done. */
static gboolean nostr_initialized = FALSE;
if (!nostr_initialized) {
if (nostr_init() != NOSTR_SUCCESS) {
return make_error("INIT_FAILED", "Failed to initialize nostr_core_lib");
}
nostr_initialized = TRUE;
}
cJSON *result = NULL;
if (strcmp(method, "local") == 0) {
result = login_local(params);
} else if (strcmp(method, "generate") == 0) {
result = login_generate(params);
} else if (strcmp(method, "seed") == 0) {
result = login_seed(params);
} else if (strcmp(method, "readonly") == 0) {
result = login_readonly(params);
} else if (strcmp(method, "nip46") == 0) {
result = login_nip46(params);
} else if (strcmp(method, "nsigner") == 0) {
result = login_nsigner(params);
} else {
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, generate, seed, readonly, nip46, or nsigner");
}
/* If login succeeded, mark that the agent performed it. */
if (result && cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
g_agent_performed_login = TRUE;
}
return result;
}
cJSON *agent_logout(void) {
app_clear_signer();
nostr_bridge_set_signer(NULL, "", TRUE);
g_print("[agent-login] Logged out\n");
return make_success(NULL);
}
cJSON *agent_switch_identity(cJSON *params) {
/* Free the old signer first. */
app_clear_signer();
/* Then login with the new identity. */
return agent_login(params);
}
gboolean agent_is_logged_in(void) {
return (app_get_signer() != NULL) ||
(app_get_method() == KEY_STORE_METHOD_READONLY && app_get_pubkey_hex()[0] != '\0');
}
gboolean agent_login_was_performed_by_agent(void) {
return g_agent_performed_login;
}
+66
View File
@@ -0,0 +1,66 @@
/*
* agent_login.h — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly, bypassing the GTK login dialog.
* Used by the agent server so an AI agent can authenticate without
* human interaction.
*/
#ifndef AGENT_LOGIN_H
#define AGENT_LOGIN_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Check current login state. Returns a JSON response (caller frees).
* Response: {"success":true,"data":{"logged_in":true/false,"method":"local","pubkey":"...","readonly":false}}
*/
cJSON *agent_login_status(void);
/*
* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields:
* - local: {"method":"local","nsec":"nsec1..."} or {"method":"local","privkey_hex":"..."}
* - seed: {"method":"seed","mnemonic":"word1 word2 ...","account":0}
* - readonly: {"method":"readonly","npub":"npub1..."} or {"method":"readonly","pubkey_hex":"..."}
* - nip46: {"method":"nip46","bunker_url":"bunker://..."}
* - nsigner: {"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}
*
* Returns JSON response (caller frees).
* On success: {"success":true,"data":{"method":"local","pubkey":"...","npub":"...","readonly":false}}
* On failure: {"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_login(cJSON *params);
/*
* Log out — clear signer, reset state. Returns JSON response (caller frees).
*/
cJSON *agent_logout(void);
/*
* Switch identity — same as login but frees the old signer first.
* Same params as agent_login. Returns JSON response (caller frees).
*/
cJSON *agent_switch_identity(cJSON *params);
/*
* Returns TRUE if logged in (signer or readonly loaded).
*/
gboolean agent_is_logged_in(void);
/*
* Returns TRUE if the agent (not the GTK dialog) performed the login.
* Used by login_dialog.c to close the dialog when the agent logs in.
*/
gboolean agent_login_was_performed_by_agent(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LOGIN_H */
+902
View File
@@ -0,0 +1,902 @@
/*
* agent_mcp.c — MCP (Model Context Protocol) server endpoint
*
* Implements the MCP Streamable HTTP transport. The AI assistant sends
* JSON-RPC POST requests to /mcp, and we respond with JSON. This uses
* the same agent_tools_dispatch() as the WebSocket endpoint.
*
* For async tools (snapshot, eval, etc.), we use a GMainLoop polling
* approach to wait for the JS result, since we're in an HTTP handler
* (not a WebSocket callback) so the polling doesn't deadlock.
*/
#include "agent_mcp.h"
#include "agent_server.h"
#include "agent_tools.h"
#include "agent_snapshot.h"
#include "agent_login.h"
#include "tab_manager.h"
#include "version.h"
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* ── Session management ────────────────────────────────────────────── *
*
* MCP "Streamable HTTP" transport uses a Mcp-Session-Id header to
* correlate requests. The server generates a UUID on `initialize`
* and returns it in the response header. Subsequent requests should
* carry the same header. Sessions expire after 1 hour of inactivity
* (checked lazily on lookup).
*/
#define MCP_SESSION_TIMEOUT_SEC 3600 /* 1 hour */
typedef struct {
char *session_id;
gboolean initialized;
gint64 last_activity; /* time(NULL) */
} mcp_session_t;
/* session_id (owned) -> mcp_session_t* (owned) */
static GHashTable *g_sessions = NULL;
static mcp_session_t *session_create(void) {
mcp_session_t *s = g_new(mcp_session_t, 1);
s->session_id = g_uuid_string_random();
s->initialized = TRUE;
s->last_activity = time(NULL);
g_hash_table_insert(g_sessions, s->session_id, s);
return s;
}
static mcp_session_t *session_lookup(const char *id) {
if (id == NULL || g_sessions == NULL) {
return NULL;
}
mcp_session_t *s = g_hash_table_lookup(g_sessions, id);
if (s == NULL) {
return NULL;
}
/* Lazy expiry check. */
if (time(NULL) - s->last_activity > MCP_SESSION_TIMEOUT_SEC) {
g_hash_table_remove(g_sessions, id);
return NULL;
}
return s;
}
static void session_touch(mcp_session_t *s) {
if (s) {
s->last_activity = time(NULL);
}
}
static void session_destroy(mcp_session_t *s) {
if (s && g_sessions) {
g_hash_table_remove(g_sessions, s->session_id);
}
}
/* ── SSE helper ────────────────────────────────────────────────────── *
*
* Wraps a JSON-RPC response string in a single SSE event:
* event: message\r\n
* data: <json>\r\n
* \r\n
* Returns a newly-allocated string that the caller must free (or pass
* to soup_server_message_set_response with SOUP_MEMORY_TAKE).
*/
static char *build_sse_response(const char *json_str) {
if (json_str == NULL) {
json_str = "{}";
}
return g_strdup_printf("event: message\r\ndata: %s\r\n\r\n", json_str);
}
/* ── JSON-RPC helpers ─────────────────────────────────────────────── */
static cJSON *rpc_result(int id, cJSON *result) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
cJSON_AddNumberToObject(resp, "id", id);
cJSON_AddItemToObject(resp, "result", result);
return resp;
}
static cJSON *rpc_error(int id, int code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
cJSON_AddNumberToObject(resp, "id", id);
cJSON *err = cJSON_CreateObject();
cJSON_AddNumberToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
/* ── Tool catalog ─────────────────────────────────────────────────── *
* Static array of tool definitions. Each has a name, description,
* and JSON Schema for input parameters.
*/
typedef struct {
const char *name;
const char *description;
const char *schema_json; /* pre-built JSON schema string */
} mcp_tool_def_t;
static mcp_tool_def_t tool_defs[] = {
/* Login tools */
{"login_status",
"Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
"{\"type\":\"object\",\"properties\":{}}"},
{"login",
"Log in to the browser with a Nostr identity. Methods: 'random' (generate a fresh random key — quickest for testing), 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"random\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\",\"description\":\"nsec1... string (method: local)\"},\"privkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex private key (method: local)\"},\"mnemonic\":{\"type\":\"string\",\"description\":\"12 or 24 BIP-39 words (method: seed)\"},\"account\":{\"type\":\"integer\",\"default\":0,\"description\":\"Account index (method: seed)\"},\"npub\":{\"type\":\"string\",\"description\":\"npub1... string (method: readonly)\"},\"pubkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex pubkey (method: readonly)\"},\"bunker_url\":{\"type\":\"string\",\"description\":\"bunker:// URL (method: nip46)\"},\"transport\":{\"type\":\"string\",\"enum\":[\"serial\",\"unix\",\"tcp\",\"qrexec\"],\"description\":\"Transport type (method: nsigner)\"},\"device\":{\"type\":\"string\",\"description\":\"Device path, socket, host:port, or qube name (method: nsigner)\"},\"service\":{\"type\":\"string\",\"default\":\"qubes.NsignerRpc\",\"description\":\"Qrexec service name (method: nsigner)\"},\"index\":{\"type\":\"integer\",\"default\":0,\"description\":\"Key index (method: nsigner)\"}},\"required\":[\"method\"]}"},
{"logout",
"Log out and clear the current Nostr identity.",
"{\"type\":\"object\",\"properties\":{}}"},
{"switch_identity",
"Switch to a new Nostr identity. Same parameters as login (including 'generate'). Frees the old signer first.",
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"generate\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\"},\"privkey_hex\":{\"type\":\"string\"},\"mnemonic\":{\"type\":\"string\"},\"npub\":{\"type\":\"string\"},\"bunker_url\":{\"type\":\"string\"},\"transport\":{\"type\":\"string\"},\"device\":{\"type\":\"string\"},\"index\":{\"type\":\"integer\"}},\"required\":[\"method\"]}"},
/* Navigation tools */
{"open",
"Navigate the active tab to a URL.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}"},
{"back",
"Go back in browser history.",
"{\"type\":\"object\",\"properties\":{}}"},
{"forward",
"Go forward in browser history.",
"{\"type\":\"object\",\"properties\":{}}"},
{"reload",
"Reload the current page (bypassing cache).",
"{\"type\":\"object\",\"properties\":{}}"},
{"get_url",
"Get the current URL of the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
{"get_title",
"Get the page title of the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Snapshot & inspection tools */
{"snapshot",
"Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements. Call this after navigation or page changes to see what's on the page.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Only show interactive elements\"},\"compact\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Remove empty structural elements\"}}}"},
{"get_text",
"Get the text content of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\",\"description\":\"Element ref from snapshot (e.g. @e1)\"},\"selector\":{\"type\":\"string\",\"description\":\"CSS selector\"}}}"},
{"get_html",
"Get the innerHTML of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_attr",
"Get an attribute of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"attr\":{\"type\":\"string\",\"description\":\"Attribute name (e.g. href, src, class)\"}},\"required\":[\"attr\"]}"},
{"get_value",
"Get the value of an input, textarea, or select element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_count",
"Count the number of elements matching a CSS selector.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"get_box",
"Get the bounding box of an element by ref or CSS selector. Returns x, y, width, height, top, right, bottom, left.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_styles",
"Get the computed CSS styles of an element by ref or CSS selector. Returns all computed style properties.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_visible",
"Check if an element is visible (not display:none, visibility:hidden, opacity:0, or offsetParent null).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_enabled",
"Check if an element is enabled (not disabled).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_checked",
"Check if a checkbox or radio element is checked.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"eval",
"Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\",\"description\":\"JavaScript to execute\"}},\"required\":[\"script\"]}"},
{"screenshot",
"Capture a screenshot of the current page as a PNG image. Returns base64-encoded image data that can be viewed by the AI assistant. Use for visual context when the accessibility tree snapshot isn't sufficient.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Interaction tools */
{"click",
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"click_at",
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
{"fill",
"Clear an input and fill it with a value.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"type",
"Type text into an element (appends to existing value).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"press",
"Press a keyboard key (e.g. Enter, Tab, Escape).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"scroll",
"Scroll the page in a direction.",
"{\"type\":\"object\",\"properties\":{\"direction\":{\"type\":\"string\",\"enum\":[\"up\",\"down\",\"left\",\"right\"]},\"amount\":{\"type\":\"integer\",\"default\":500}},\"required\":[\"direction\"]}"},
{"hover",
"Hover over an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"focus",
"Focus an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"close",
"Close the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Tab tools */
{"tab_list",
"List all open tabs with their URLs and titles.",
"{\"type\":\"object\",\"properties\":{}}"},
{"tab_new",
"Open a new tab, optionally with a URL.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}}}"},
{"tab_switch",
"Switch to a tab by index.",
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}},\"required\":[\"index\"]}"},
{"tab_close",
"Close a tab by index. If no index, closes the active tab.",
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}}}"},
/* Wait tools */
{"wait",
"Wait for a specified number of milliseconds.",
"{\"type\":\"object\",\"properties\":{\"ms\":{\"type\":\"integer\",\"default\":1000}}}"},
{"wait_for",
"Wait for an element to appear on the page.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"selector\"]}"},
/* Extended interaction tools */
{"dblclick",
"Double-click an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"select",
"Select an option in a dropdown element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"check",
"Check a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"uncheck",
"Uncheck a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"scroll_into_view",
"Scroll an element into view by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"keyboard_type",
"Type text into the focused element with real keystroke events (keydown, keypress, keyup per character).",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"insert_text",
"Insert text at the current cursor position without key events. Uses document.execCommand.",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"keydown",
"Dispatch a keydown event for a key (hold key down).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"keyup",
"Dispatch a keyup event for a key (release key).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"drag",
"Drag an element and drop it onto another element (HTML5 drag events).",
"{\"type\":\"object\",\"properties\":{\"src_ref\":{\"type\":\"string\"},\"src_selector\":{\"type\":\"string\"},\"tgt_ref\":{\"type\":\"string\"},\"tgt_selector\":{\"type\":\"string\"}}}"},
{"close_all",
"Close all open tabs.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Find element tools */
{"find_role",
"Find an element by ARIA role (e.g. button, link, textbox, navigation). Optionally filter by name (aria-label or text content). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"role\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"}},\"required\":[\"role\"]}"},
{"find_text",
"Find an element by text content. Set exact=true for exact match. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"exact\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"text\"]}"},
{"find_label",
"Find an element by associated label (label[for], aria-label, or aria-labelledby). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\"}},\"required\":[\"label\"]}"},
{"find_placeholder",
"Find an element by placeholder text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"placeholder\":{\"type\":\"string\"}},\"required\":[\"placeholder\"]}"},
{"find_alt",
"Find an element by alt text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"alt\":{\"type\":\"string\"}},\"required\":[\"alt\"]}"},
{"find_title",
"Find an element by title attribute (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"]}"},
{"find_testid",
"Find an element by data-testid attribute (exact match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"testid\":{\"type\":\"string\"}},\"required\":[\"testid\"]}"},
{"find_first",
"Find the first element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_last",
"Find the last element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_nth",
"Find the nth element (0-based) matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"n\":{\"type\":\"integer\"}},\"required\":[\"selector\",\"n\"]}"},
{"wait_for_text",
"Wait for specific text to appear on the page. Polls until the text is found in document.body.innerText or timeout.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"text\"]}"},
{"wait_for_url",
"Wait for the page URL to match a pattern. By default does a substring match; set regex=true for regex matching.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000},\"regex\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"url\"]}"},
{"wait_for_load",
"Wait for the page to finish loading (webkit_web_view_is_loading returns false).",
"{\"type\":\"object\",\"properties\":{\"timeout\":{\"type\":\"integer\",\"default\":10000}}}"},
{"wait_for_fn",
"Wait for a JavaScript expression to evaluate to truthy. The script is evaluated repeatedly until it returns true or timeout.",
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"script\"]}"},
{"batch",
"Execute multiple tool commands in sequence. Each command has 'tool', 'params', and optional 'id'. Set continueOnError=true to continue after failures.",
"{\"type\":\"object\",\"properties\":{\"commands\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"tool\":{\"type\":\"string\"},\"params\":{\"type\":\"object\"},\"id\":{\"type\":[\"integer\",\"string\"]}}}},\"continueOnError\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"commands\"]}"},
/* Cookies & web storage tools */
{"cookies_get",
"Get all cookies for the current page (non-httpOnly cookies visible to JavaScript).",
"{\"type\":\"object\",\"properties\":{}}"},
{"cookies_set",
"Set a cookie. Parameters: name, value, domain (optional), path (default /), secure, max_age (-1 for session cookie).",
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"},\"domain\":{\"type\":\"string\"},\"path\":{\"type\":\"string\",\"default\":\"/\"},\"secure\":{\"type\":\"boolean\",\"default\":false},\"http_only\":{\"type\":\"boolean\",\"default\":false},\"max_age\":{\"type\":\"integer\",\"default\":-1}},\"required\":[\"name\",\"value\"]}"},
{"cookies_clear",
"Clear all cookies for the current page.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get",
"Get all localStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get_key",
"Get a specific localStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_local_set",
"Set a localStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_local_clear",
"Clear all localStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get",
"Get all sessionStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get_key",
"Get a specific sessionStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_session_set",
"Set a sessionStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_session_clear",
"Clear all sessionStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Mouse tools */
{"mouse_move",
"Move the mouse to the specified coordinates (clientX, clientY).",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}},\"required\":[\"x\",\"y\"]}"},
{"mouse_down",
"Press a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_up",
"Release a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_wheel",
"Scroll the mouse wheel by dy (vertical) and dx (horizontal) pixels.",
"{\"type\":\"object\",\"properties\":{\"dy\":{\"type\":\"integer\"},\"dx\":{\"type\":\"integer\",\"default\":0}},\"required\":[\"dy\"]}"},
/* Clipboard tools */
{"clipboard_read",
"Read text from the system clipboard.",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_write",
"Write text to the system clipboard.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}"},
{"clipboard_copy",
"Copy the current selection to clipboard (document.execCommand('copy')).",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_paste",
"Paste from clipboard into the focused element (document.execCommand('paste')).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Settings tools */
{"set_viewport",
"Set the browser window/viewport size in pixels.",
"{\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"}},\"required\":[\"width\",\"height\"]}"},
{"set_offline",
"Toggle offline mode (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true}}}"},
{"set_headers",
"Set extra HTTP headers for all requests (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"headers\":{\"type\":\"object\"}}}"},
{"set_credentials",
"Set HTTP basic auth credentials (not yet supported — auth is interactive in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}},\"required\":[\"username\",\"password\"]}"},
{"set_media",
"Emulate color scheme (dark or light).",
"{\"type\":\"object\",\"properties\":{\"scheme\":{\"type\":\"string\",\"enum\":[\"dark\",\"light\"]}},\"required\":[\"scheme\"]}"},
/* Frame tools */
{"frame_switch",
"Switch to an iframe by ref or CSS selector. Subsequent JS-based tools will execute in the frame context.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"frame_main",
"Switch back to the main frame (exit any iframe context).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Dialog tools */
{"dialog_accept",
"Accept a pending JavaScript dialog (alert, confirm, or prompt). For prompt dialogs, provide 'text' for the input value.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}"},
{"dialog_dismiss",
"Dismiss a pending JavaScript dialog (cancel).",
"{\"type\":\"object\",\"properties\":{}}"},
{"dialog_status",
"Check if a JavaScript dialog (alert/confirm/prompt) is pending. Returns the dialog type and message if pending.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Debug tools */
{"console",
"Get collected console messages from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"errors",
"Get JavaScript errors from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"highlight",
"Highlight an element with a temporary red outline. Useful for debugging.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"duration\":{\"type\":\"integer\",\"default\":2000}}}"},
/* State tools */
{"state_save",
"Save browser state (localStorage and cookies) to a file or return as JSON string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"},
{"state_load",
"Load browser state from a file or JSON string. Restores localStorage and cookies.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"state\":{\"type\":\"string\"}}}"},
/* Complex tools */
{"upload",
"Upload files to a file input element. Provide file paths on the local filesystem. Files are read, base64-encoded, and set on the input via DataTransfer API.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"files\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"files\"]}"},
{"pdf",
"Save the current page as a PDF file. Provide a file path for the output.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}"},
{"screenshot_annotated",
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
};
static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]);
/* ── Build tools/list response ────────────────────────────────────── */
static cJSON *build_tools_list(void) {
cJSON *tools = cJSON_CreateArray();
for (int i = 0; i < tool_defs_count; i++) {
cJSON *tool = cJSON_CreateObject();
cJSON_AddStringToObject(tool, "name", tool_defs[i].name);
cJSON_AddStringToObject(tool, "description", tool_defs[i].description);
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
if (schema) {
cJSON_AddItemToObject(tool, "inputSchema", schema);
}
cJSON_AddItemToArray(tools, tool);
}
return tools;
}
/* ── Async result holder (for JS-based tools) ─────────────────────── */
typedef struct {
cJSON *response;
gboolean done;
GMainLoop *loop;
} mcp_async_ctx_t;
/* Callback for async JS evaluation — stores result and quits the loop. */
static void mcp_async_callback(cJSON *response, gpointer user_data) {
mcp_async_ctx_t *ctx = (mcp_async_ctx_t *)user_data;
ctx->response = response;
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
/* We need a wrapper to adapt agent_js_eval_async's callback signature
* to our mcp_async_ctx_t. The agent_js_eval_async sends the response
* through a WebSocket connection, but for MCP we need to capture it.
* Instead of using agent_js_eval_async, we'll call agent_tools_dispatch
* with a special "capture" mode. */
/* Actually, the simplest approach for MCP: use agent_tools_dispatch
* with NULL connection. When conn is NULL, the async tools fall through
* to the sync path (which uses agent_js_eval_sync). The sync path
* works fine in HTTP handlers because we're not inside a WebSocket
* callback. Let's verify this works. */
/* ── MCP request handler ──────────────────────────────────────────── */
static void on_mcp_request(SoupServer *server,
SoupServerMessage *msg,
const char *path,
GHashTable *query,
gpointer user_data) {
(void)server;
(void)query;
(void)user_data;
if (g_strcmp0(path, "/mcp") != 0) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
const char *method = soup_server_message_get_method(msg);
/* ── GET: open an SSE stream for server→client push events ──── */
if (g_strcmp0(method, "GET") == 0) {
/* Pragmatic approach: return a valid SSE content-type with an
* initial connection comment. A truly long-lived push stream
* can be added later; for now this satisfies clients that
* probe GET /mcp and expect text/event-stream. */
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
const char *sse_init = ": connected\r\n\r\n";
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_STATIC, sse_init, strlen(sse_init));
return;
}
/* ── DELETE: terminate a session ─────────────────────────────── */
if (g_strcmp0(method, "DELETE") == 0) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid == NULL) {
soup_server_message_set_status(msg, 400, NULL);
return;
}
mcp_session_t *s = session_lookup(sid);
if (s == NULL) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
session_destroy(s);
soup_server_message_set_status(msg, 200, NULL);
return;
}
/* Only POST is handled below. */
if (g_strcmp0(method, "POST") != 0) {
soup_server_message_set_status(msg, 405, NULL);
return;
}
/* Read the request body. */
SoupMessageBody *body = soup_server_message_get_request_body(msg);
gsize size = body ? body->length : 0;
const gchar *data = body ? body->data : NULL;
if (data == NULL || size == 0) {
soup_server_message_set_status(msg, 400, NULL);
return;
}
/* Parse JSON-RPC request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
const char *err = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}";
char *sse = build_sse_response(err);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
return;
}
/* Extract JSON-RPC fields. */
const char *rpc_method = cJSON_GetStringValue(cJSON_GetObjectItem(request, "method"));
cJSON *id_json = cJSON_GetObjectItem(request, "id");
cJSON *params = cJSON_GetObjectItem(request, "params");
int rpc_id = (id_json && cJSON_IsNumber(id_json)) ? (int)id_json->valuedouble : 0;
/* ── Session validation ──────────────────────────────────────── *
* For `initialize` we create a new session. For all other methods,
* we check the Mcp-Session-Id header. If the header is present but
* the session is unknown/expired, return 404. If the header is
* absent, we proceed (lenient) for backward compatibility. */
mcp_session_t *session = NULL;
gboolean is_initialize = (rpc_method && strcmp(rpc_method, "initialize") == 0);
if (!is_initialize) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid != NULL) {
session = session_lookup(sid);
if (session == NULL) {
/* Unknown or expired session — create a new one and return
* the new session ID in the response header so the client
* can update its cached session ID. This handles browser
* restarts gracefully without requiring the client to
* re-initialize. */
session = session_create();
SoupMessageHeaders *resp_hdrs =
soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs,
"Mcp-Session-Id", session->session_id);
g_print("[mcp] Stale session '%s' — created new session '%s'\n",
sid, session->session_id);
}
session_touch(session);
} else {
g_warning("[mcp] Request without Mcp-Session-Id header (method=%s) — proceeding leniently",
rpc_method ? rpc_method : "?");
}
}
cJSON *response = NULL;
if (rpc_method == NULL) {
response = rpc_error(rpc_id, -32600, "Invalid Request");
} else if (is_initialize) {
/* Create a new session and return its ID in the response header. */
session = session_create();
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Mcp-Session-Id", session->session_id);
/* Return server info and capabilities. */
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "protocolVersion", "2024-11-05");
cJSON *caps = cJSON_CreateObject();
cJSON_AddItemToObject(caps, "tools", cJSON_CreateObject());
cJSON_AddItemToObject(result, "capabilities", caps);
cJSON *server_info = cJSON_CreateObject();
cJSON_AddStringToObject(server_info, "name", "sovereign-browser");
cJSON_AddStringToObject(server_info, "version", SB_VERSION);
cJSON_AddItemToObject(result, "serverInfo", server_info);
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "tools/list") == 0) {
/* Return the tool catalog. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "tools", build_tools_list());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "tools/call") == 0) {
/* Dispatch the tool call. */
const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(params, "name"));
cJSON *arguments = cJSON_GetObjectItem(params, "arguments");
if (tool_name == NULL) {
response = rpc_error(rpc_id, -32602, "Missing 'name' in params");
} else {
/* Build a request in the format agent_tools_dispatch expects. */
cJSON *tool_request = cJSON_CreateObject();
cJSON_AddStringToObject(tool_request, "tool", tool_name);
if (arguments) {
cJSON_AddItemReferenceToObject(tool_request, "params", arguments);
} else {
cJSON_AddItemToObject(tool_request, "params", cJSON_CreateObject());
}
if (id_json) {
cJSON_AddItemReferenceToObject(tool_request, "id", id_json);
}
/* Call dispatch with NULL connection — async tools will
* fall through to the sync path (agent_js_eval_sync),
* which works in HTTP handlers. */
cJSON *tool_response = agent_tools_dispatch(tool_request, NULL);
cJSON_Delete(tool_request);
if (tool_response == NULL) {
/* This shouldn't happen with NULL conn since async tools
* fall back to sync. But handle it just in case. */
response = rpc_error(rpc_id, -32603, "Tool returned no response");
} else {
/* Convert tool response to MCP format.
* MCP tools/call returns: {content: [{type: "text", text: "..."}],
* isError: false}
*
* For the screenshot tool, the response contains a
* data.screenshot field with base64 PNG data. In that
* case we emit an image content block per the MCP spec
* instead of a text block. */
cJSON *result = cJSON_CreateObject();
cJSON *content = cJSON_CreateArray();
cJSON *data_obj = cJSON_GetObjectItem(tool_response, "data");
cJSON *screenshot = data_obj ? cJSON_GetObjectItem(data_obj, "screenshot") : NULL;
if (screenshot && cJSON_IsString(screenshot)) {
/* Image content block per MCP spec. */
cJSON *img_item = cJSON_CreateObject();
cJSON_AddStringToObject(img_item, "type", "image");
cJSON_AddStringToObject(img_item, "data", screenshot->valuestring);
cJSON_AddStringToObject(img_item, "mimeType", "image/png");
cJSON_AddItemToArray(content, img_item);
/* If the response also includes a snapshot text tree
* (e.g. from screenshot_annotated), emit it as an
* additional text content block. */
cJSON *snapshot_text = cJSON_GetObjectItem(data_obj, "snapshot");
if (snapshot_text && cJSON_IsString(snapshot_text)) {
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "type", "text");
cJSON_AddStringToObject(text_item, "text", snapshot_text->valuestring);
cJSON_AddItemToArray(content, text_item);
}
} else {
/* Default: text content block with the JSON response. */
char *resp_str = cJSON_PrintUnformatted(tool_response);
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "type", "text");
cJSON_AddStringToObject(text_item, "text", resp_str ? resp_str : "{}");
cJSON_AddItemToArray(content, text_item);
free(resp_str);
}
cJSON_AddItemToObject(result, "content", content);
gboolean is_error = !cJSON_IsTrue(cJSON_GetObjectItem(tool_response, "success"));
cJSON_AddBoolToObject(result, "isError", is_error);
cJSON_Delete(tool_response);
response = rpc_result(rpc_id, result);
}
}
} else if (strcmp(rpc_method, "ping") == 0) {
/* MCP ping — health check. Return an empty result. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (strcmp(rpc_method, "resources/list") == 0) {
/* We don't expose resources — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resources", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "resources/templates/list") == 0) {
/* No resource templates — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resourceTemplates", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "prompts/list") == 0) {
/* We don't expose prompts — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "prompts", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "logging/setLevel") == 0) {
/* Accept any log level — we don't filter, but acknowledge. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (g_str_has_prefix(rpc_method, "notifications/")) {
/* MCP notifications (initialized, cancelled, progress, etc.)
* have no id and expect no response body. Return 202 Accepted. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else if (id_json == NULL) {
/* Any request without an id is a notification per JSON-RPC spec.
* Return 202 Accepted with no body. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else {
response = rpc_error(rpc_id, -32601, "Method not found");
}
cJSON_Delete(request);
/* Send the response as SSE (Server-Sent Events).
* Notifications (no id) already returned 202 above and don't reach
* here. All JSON-RPC responses with an id are wrapped in a single
* SSE event: "event: message\r\ndata: <json>\r\n\r\n" */
if (response) {
char *resp_str = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
if (resp_str) {
char *sse = build_sse_response(resp_str);
free(resp_str);
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
} else {
soup_server_message_set_status(msg, 500, NULL);
}
} else {
soup_server_message_set_status(msg, 500, NULL);
}
}
/* ── Public API ───────────────────────────────────────────────────── */
void agent_mcp_register(SoupServer *server) {
/* Initialize the session table if not yet created. */
if (g_sessions == NULL) {
g_sessions = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, g_free);
}
soup_server_add_handler(server, "/mcp", on_mcp_request, NULL, NULL);
g_print("[agent] MCP endpoint: http://localhost:%d/mcp\n",
agent_server_get_port());
}
+32
View File
@@ -0,0 +1,32 @@
/*
* agent_mcp.h — MCP (Model Context Protocol) server endpoint
*
* Implements the MCP Streamable HTTP transport on top of our existing
* SoupServer. Exposes browser tools as MCP tools for AI assistants
* (Roo Code, Claude Code, Cursor, etc.).
*
* The endpoint is at http://localhost:PORT/mcp and accepts JSON-RPC
* POST requests. It uses the same agent_tools_dispatch() as the
* WebSocket endpoint — same code path, same tools.
*/
#ifndef AGENT_MCP_H
#define AGENT_MCP_H
#include <libsoup/soup.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Register the MCP handler at /mcp on the given SoupServer.
* Call this after soup_server_add_websocket_handler() in agent_server_start().
*/
void agent_mcp_register(SoupServer *server);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_MCP_H */
+306
View File
@@ -0,0 +1,306 @@
/*
* agent_server.c — WebSocket server for agent tool commands
*
* Uses libsoup's SoupServer to provide a WebSocket endpoint at /agent.
* External AI agents connect, send JSON tool commands, and receive
* JSON responses. The server also supports plain HTTP GET to / for
* status discovery (curl-testable without a WebSocket client).
*/
#include "agent_server.h"
#include "agent_mcp.h"
#include "settings.h"
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* Forward declarations from agent_tools.c */
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
/* ── Static state ─────────────────────────────────────────────────── */
static SoupServer *g_server = NULL;
static int g_port = 0;
static gboolean g_running = FALSE;
static GPtrArray *g_clients = NULL; /* array of SoupWebsocketConnection* */
static agent_login_callback_t g_login_cb = NULL;
/* ── Client management ────────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data);
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data);
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data);
/* Forward declarations */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data);
static void track_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) {
g_clients = g_ptr_array_new();
}
/* Take an extra reference so the connection isn't destroyed when
* the websocket handler callback returns. */
g_object_ref(conn);
g_ptr_array_add(g_clients, conn);
g_signal_connect(conn, "message", G_CALLBACK(on_ws_message), NULL);
g_signal_connect(conn, "closed", G_CALLBACK(on_ws_closed), NULL);
g_signal_connect(conn, "error", G_CALLBACK(on_ws_error), NULL);
g_print("[agent] Client connected (%d total)\n", g_clients->len);
}
static void untrack_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) return;
/* Remove from array and release our reference. */
g_ptr_array_remove_fast(g_clients, conn);
g_object_unref(conn);
g_print("[agent] Client disconnected (%d remaining)\n",
g_clients ? g_clients->len : 0);
}
/* ── Send helpers ─────────────────────────────────────────────────── */
static void send_json_to_client(SoupWebsocketConnection *conn, const char *json_str) {
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
return;
}
soup_websocket_connection_send_text(conn, json_str);
}
static void send_json_to_all(const char *json_str) {
if (g_clients == NULL) return;
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
send_json_to_client(conn, json_str);
}
}
/* ── WebSocket callbacks ──────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data) {
(void)user_data;
(void)type;
gsize size = 0;
const gchar *data = g_bytes_get_data(message, &size);
if (data == NULL || size == 0) return;
/* Parse the JSON request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INVALID_JSON\","
"\"message\":\"Failed to parse JSON request\"}}";
send_json_to_client(conn, err_json);
return;
}
/* Dispatch the tool. Pass the WebSocket connection so async tools
* (snapshot, eval) can send their response directly when JS completes. */
cJSON *response = agent_tools_dispatch(request, conn);
cJSON_Delete(request);
if (response == NULL) {
/* NULL means the tool is sending its response asynchronously
* (e.g. snapshot, eval). Don't send anything here. */
return;
}
/* Send the response. */
char *response_str = cJSON_PrintUnformatted(response);
if (response_str) {
send_json_to_client(conn, response_str);
free(response_str);
}
cJSON_Delete(response);
}
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
untrack_client(conn);
}
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
g_printerr("[agent] WebSocket error on client\n");
untrack_client(conn);
}
/* ── WebSocket handler (called by SoupServer on new connection) ───── */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data) {
(void)server;
(void)path;
(void)msg;
(void)user_data;
track_client(conn);
}
/* ── HTTP handler for status (GET /) ──────────────────────────────── */
static void on_http_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
GHashTable *query,
gpointer user_data) {
(void)server;
(void)query;
(void)user_data;
if (g_strcmp0(path, "/") != 0) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
/* Build a status JSON response. */
cJSON *status = cJSON_CreateObject();
cJSON_AddStringToObject(status, "name", "sovereign_browser agent server");
cJSON_AddBoolToObject(status, "running", g_running);
cJSON_AddNumberToObject(status, "port", g_port);
cJSON_AddNumberToObject(status, "clients", g_clients ? g_clients->len : 0);
/* Check login state via agent_tools (delegates to agent_login). */
extern gboolean agent_is_logged_in(void);
cJSON_AddBoolToObject(status, "logged_in", agent_is_logged_in());
char *json_str = cJSON_PrintUnformatted(status);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "application/json",
SOUP_MEMORY_TAKE, json_str, strlen(json_str));
cJSON_Delete(status);
}
/* ── Public API ───────────────────────────────────────────────────── */
int agent_server_start(int port) {
if (g_running) {
g_print("[agent] Server already running on port %d\n", g_port);
return 0;
}
GError *error = NULL;
g_server = soup_server_new("server-header", "sovereign_browser-agent", NULL);
if (g_server == NULL) {
g_printerr("[agent] Failed to create SoupServer\n");
return -1;
}
/* Add HTTP handler for status at /. */
soup_server_add_handler(g_server, "/", on_http_handler, NULL, NULL);
/* Add WebSocket handler at /agent. */
soup_server_add_websocket_handler(g_server, "/agent", NULL, NULL,
on_websocket_handler, NULL, NULL);
/* Add MCP handler at /mcp. */
agent_mcp_register(g_server);
/* Listen on the specified port (0 = auto-assign). */
soup_server_listen_local(g_server, port, 0, &error);
if (error != NULL) {
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
g_error_free(error);
g_object_unref(g_server);
g_server = NULL;
return -1;
}
/* Get the actual bound port. */
GSList *uris = soup_server_get_uris(g_server);
if (uris != NULL) {
GUri *uri = uris->data;
g_port = g_uri_get_port(uri);
g_slist_free(uris);
} else {
g_port = port;
}
g_running = TRUE;
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
return 0;
}
void agent_server_stop(void) {
if (!g_running) return;
/* Close all client connections and release our references. */
if (g_clients != NULL) {
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
soup_websocket_connection_close(conn, SOUP_WEBSOCKET_CLOSE_GOING_AWAY,
"server shutting down");
g_object_unref(conn);
}
g_ptr_array_free(g_clients, TRUE);
g_clients = NULL;
}
if (g_server != NULL) {
g_object_unref(g_server);
g_server = NULL;
}
g_running = FALSE;
g_port = 0;
g_print("[agent] Server stopped\n");
}
int agent_server_get_port(void) {
return g_port;
}
gboolean agent_server_is_running(void) {
return g_running;
}
int agent_server_get_client_count(void) {
return g_clients ? g_clients->len : 0;
}
void agent_server_emit_event(const char *event_name, cJSON *data) {
if (!g_running || g_clients == NULL || g_clients->len == 0) {
if (data) cJSON_Delete(data);
return;
}
cJSON *event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "event");
cJSON_AddStringToObject(event, "event", event_name);
if (data) {
cJSON_AddItemToObject(event, "data", data);
}
char *json_str = cJSON_PrintUnformatted(event);
if (json_str) {
send_json_to_all(json_str);
free(json_str);
}
cJSON_Delete(event);
}
void agent_server_set_login_callback(agent_login_callback_t cb) {
g_login_cb = cb;
}
/* Called by agent_login.c when login succeeds — notifies main.c. */
void agent_server_notify_login(void) {
if (g_login_cb) {
g_login_cb();
}
}
+74
View File
@@ -0,0 +1,74 @@
/*
* agent_server.h — WebSocket server for agent tool commands
*
* Embeds a WebSocket server in the browser process using libsoup's
* SoupServer. External AI agents connect to ws://localhost:PORT/agent
* and send JSON tool commands (navigate, snapshot, click, etc.).
*
* The server starts before the login dialog so agents can authenticate
* without human interaction. Before login, only login tools are available;
* after login, all browser tools become available.
*/
#ifndef AGENT_SERVER_H
#define AGENT_SERVER_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Start the agent WebSocket server on the given port.
* Returns 0 on success, -1 on failure.
*/
int agent_server_start(int port);
/*
* Stop the agent server and close all connections.
*/
void agent_server_stop(void);
/*
* Get the port the server is listening on.
* Returns 0 if not running.
*/
int agent_server_get_port(void);
/*
* Check if the server is running.
*/
gboolean agent_server_is_running(void);
/*
* Get the number of connected clients.
*/
int agent_server_get_client_count(void);
/*
* Broadcast a push event to all connected clients.
* The event JSON is: {"type":"event","event":<name>,"data":<data>}
* Takes ownership of data (frees it).
*/
void agent_server_emit_event(const char *event_name, cJSON *data);
/*
* Set a callback that is called when a login tool succeeds.
* This allows main.c to proceed past the login wait loop.
*/
typedef void (*agent_login_callback_t)(void);
void agent_server_set_login_callback(agent_login_callback_t cb);
/*
* Called by agent_login.c when login succeeds — notifies main.c
* via the callback set by agent_server_set_login_callback().
*/
void agent_server_notify_login(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SERVER_H */
+449
View File
@@ -0,0 +1,449 @@
/*
* agent_snapshot.c — accessibility tree snapshot for agent tools
*
* Injects a JS script that walks the DOM, assigns refs to interactive
* elements, and returns a text tree. The ref map is stored in
* window.__agentRefs for later use by click/fill/type tools.
*/
#include "agent_snapshot.h"
#include <string.h>
#include <stdlib.h>
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
* on the default context to wait for the result. The key is to acquire
* the context before creating the loop so that the WebKit IPC sources
* are properly dispatched.
*/
typedef struct {
GMainLoop *loop;
char *result;
gboolean done;
} js_eval_ctx_t;
static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value != NULL) {
char *str = jsc_value_to_string(value);
if (str != NULL) {
ctx->result = g_strdup(str);
free(str);
}
g_object_unref(value);
}
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
static gboolean on_js_timeout(gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
if (!ctx->done) {
g_printerr("[agent] JS evaluation timed out\n");
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
return G_SOURCE_REMOVE;
}
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms) {
if (webview == NULL || script == NULL) return NULL;
js_eval_ctx_t ctx = {0};
/* Use the default main context for the nested loop. */
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
/* Add a timeout source to the same context. */
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
/* Start async evaluation. */
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_js_finished, &ctx);
/* Run the nested loop until JS completes or timeout. */
g_main_loop_run(ctx.loop);
/* Cleanup. */
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
g_main_context_release(main_ctx);
return ctx.result;
}
/* ── Async JS evaluation ──────────────────────────────────────────── *
* For tools that need JS return values (snapshot, eval, get_text, etc.),
* we use async evaluation. The JS completion callback sends the
* WebSocket response directly, avoiding the nested main loop issue.
*/
typedef struct {
SoupWebsocketConnection *conn;
int request_id;
char *tool_name;
js_result_handler_t handler;
} async_js_ctx_t;
static void send_ws_response(SoupWebsocketConnection *conn, cJSON *response) {
if (conn == NULL || response == NULL) return;
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
cJSON_Delete(response);
return;
}
char *str = cJSON_PrintUnformatted(response);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(response);
}
static cJSON *make_success_resp(int id, cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", id);
if (data) cJSON_AddItemToObject(resp, "data", data);
return resp;
}
static cJSON *make_error_resp(int id, const char *code, const char *msg) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON_AddNumberToObject(resp, "id", id);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", msg);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static void on_async_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
async_js_ctx_t *ctx = (async_js_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS evaluation returned NULL"));
goto cleanup;
}
char *str = jsc_value_to_string(value);
g_object_unref(value);
if (str == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS returned null"));
goto cleanup;
}
char *result = g_strdup(str);
free(str);
if (ctx->handler) {
/* Let the tool-specific handler transform the result. */
cJSON *response = ctx->handler(result);
g_free(result);
if (response) {
/* Add the request id. */
cJSON_AddNumberToObject(response, "id", ctx->request_id);
send_ws_response(ctx->conn, response);
} else {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "TOOL_FAILED", "Tool handler returned NULL"));
}
} else {
/* No handler — return raw result. */
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "result", result);
g_free(result);
send_ws_response(ctx->conn, make_success_resp(ctx->request_id, data));
}
cleanup:
g_free(ctx->tool_name);
g_free(ctx);
}
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler) {
if (webview == NULL || script == NULL) return FALSE;
async_js_ctx_t *ctx = g_new0(async_js_ctx_t, 1);
ctx->conn = conn;
ctx->request_id = request_id;
ctx->tool_name = g_strdup(tool_name ? tool_name : "unknown");
ctx->handler = handler;
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_async_js_finished, ctx);
return TRUE;
}
/* ── Snapshot JS script ───────────────────────────────────────────── *
* This script walks the DOM, assigns refs to interactive elements,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
const char *AGENT_SNAPSHOT_JS =
"(function() {\n"
" 'use strict';\n"
"\n"
" var interactiveOnly = arguments[0] || false;\n"
" var compact = arguments[1] || false;\n"
"\n"
" var refMap = {};\n"
" var refCount = 0;\n"
" var treeLines = [];\n"
"\n"
" /* Check if an element is interactive. */\n"
" function isInteractive(el) {\n"
" var tag = el.tagName.toLowerCase();\n"
" if (tag === 'a' && el.href) return true;\n"
" if (tag === 'button') return true;\n"
" if (tag === 'input' && el.type !== 'hidden') return true;\n"
" if (tag === 'select') return true;\n"
" if (tag === 'textarea') return true;\n"
" if (tag === 'summary') return true;\n"
" if (tag === 'details') return true;\n"
" if (el.hasAttribute('role')) {\n"
" var role = el.getAttribute('role');\n"
" if (['button','link','checkbox','radio','textbox','searchbox',\n"
" 'slider','spinbutton','tab','tablist','menuitem','combobox',\n"
" 'option','switch','treeitem'].indexOf(role) >= 0) return true;\n"
" }\n"
" if (el.hasAttribute('tabindex') && el.tabIndex >= 0) return true;\n"
" if (el.hasAttribute('onclick')) return true;\n"
" return false;\n"
" }\n"
"\n"
" /* Get the accessible name for an element. */\n"
" function getName(el) {\n"
" if (el.hasAttribute('aria-label')) return el.getAttribute('aria-label');\n"
" if (el.hasAttribute('aria-labelledby')) {\n"
" var lbl = document.getElementById(el.getAttribute('aria-labelledby'));\n"
" if (lbl) return lbl.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'input' || el.tagName.toLowerCase() === 'textarea') {\n"
" if (el.hasAttribute('placeholder')) return el.placeholder;\n"
" var lbl2 = document.querySelector('label[for=\"' + el.id + '\"]');\n"
" if (lbl2) return lbl2.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'select') {\n"
" if (el.options.length > 0) return el.options[el.selectedIndex].text;\n"
" }\n"
" var text = el.textContent.trim();\n"
" if (text.length > 80) text = text.substring(0, 77) + '...';\n"
" return text;\n"
" }\n"
"\n"
" /* Get the role for an element. */\n"
" function getRole(el) {\n"
" if (el.hasAttribute('role')) return el.getAttribute('role');\n"
" var tag = el.tagName.toLowerCase();\n"
" var map = {\n"
" 'a': 'link', 'button': 'button', 'input': 'textbox',\n"
" 'select': 'combobox', 'textarea': 'textbox', 'img': 'image',\n"
" 'h1': 'heading', 'h2': 'heading', 'h3': 'heading',\n"
" 'h4': 'heading', 'h5': 'heading', 'h6': 'heading',\n"
" 'nav': 'navigation', 'main': 'main', 'article': 'article',\n"
" 'section': 'region', 'form': 'form', 'ul': 'list',\n"
" 'ol': 'list', 'li': 'listitem', 'table': 'table',\n"
" 'label': 'label', 'p': 'paragraph', 'summary': 'button'\n"
" };\n"
" if (tag === 'input') {\n"
" var type = (el.type || 'text').toLowerCase();\n"
" if (type === 'checkbox') return 'checkbox';\n"
" if (type === 'radio') return 'radio';\n"
" if (type === 'button' || type === 'submit' || type === 'reset') return 'button';\n"
" if (type === 'search') return 'searchbox';\n"
" if (type === 'range') return 'slider';\n"
" if (type === 'number') return 'spinbutton';\n"
" return 'textbox';\n"
" }\n"
" return map[tag] || 'generic';\n"
" }\n"
"\n"
" /* Generate a unique CSS selector for an element. */\n"
" function getSelector(el) {\n"
" if (el.id) return '#' + el.id;\n"
" var path = [];\n"
" while (el && el.nodeType === 1 && el !== document.body) {\n"
" var part = el.tagName.toLowerCase();\n"
" if (el.className && typeof el.className === 'string') {\n"
" var classes = el.className.trim().split(/\\s+/).slice(0, 2);\n"
" if (classes.length > 0 && classes[0]) part += '.' + classes.join('.');\n"
" }\n"
" var parent = el.parentNode;\n"
" if (parent) {\n"
" var siblings = Array.prototype.filter.call(parent.children,\n"
" function(c) { return c.tagName === el.tagName; });\n"
" if (siblings.length > 1) {\n"
" part += ':nth-of-type(' + (siblings.indexOf(el) + 1) + ')';\n"
" }\n"
" }\n"
" path.unshift(part);\n"
" el = parent;\n"
" }\n"
" return path.length > 0 ? path.join(' > ') : 'body';\n"
" }\n"
"\n"
" /* Walk the DOM. */\n"
" function walk(el, depth) {\n"
" if (!el || el.nodeType !== 1) return;\n"
"\n"
" var role = getRole(el);\n"
" var name = getName(el);\n"
" var interactive = isInteractive(el);\n"
"\n"
" /* Skip non-interactive elements in interactive-only mode. */\n"
" if (interactiveOnly && !interactive) {\n"
" /* Still walk children — they might be interactive. */\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth);\n"
" }\n"
" return;\n"
" }\n"
"\n"
" /* Skip empty elements in compact mode. */\n"
" if (compact && !interactive && !name && el.children.length === 0) return;\n"
"\n"
" var indent = '';\n"
" for (var d = 0; d < depth; d++) indent += ' ';\n"
"\n"
" var line = indent + '- ' + role;\n"
" if (name) line += ' \"' + name + '\"';\n"
"\n"
" if (interactive) {\n"
" refCount++;\n"
" var ref = 'e' + refCount;\n"
" var selector = getSelector(el);\n"
" var rect = el.getBoundingClientRect();\n"
" refMap[ref] = {\n"
" role: role,\n"
" name: name,\n"
" selector: selector,\n"
" tag: el.tagName.toLowerCase(),\n"
" href: el.href || '',\n"
" type: el.type || '',\n"
" value: el.value || '',\n"
" bbox: {\n"
" x: rect.x,\n"
" y: rect.y,\n"
" width: rect.width,\n"
" height: rect.height\n"
" }\n"
" };\n"
" line += ' [ref=' + ref + ']';\n"
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"
" line += ' [type=' + el.type + ']';\n"
" }\n"
" }\n"
"\n"
" if (el.tagName && el.tagName.match(/^H[1-6]$/)) {\n"
" line += ' [level=' + el.tagName[1] + ']';\n"
" }\n"
"\n"
" treeLines.push(line);\n"
"\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth + 1);\n"
" }\n"
" }\n"
"\n"
" /* Start from body. */\n"
" walk(document.body, 0);\n"
"\n"
" /* Store ref map globally for later use by click/fill tools. */\n"
" window.__agentRefs = refMap;\n"
"\n"
" /* Return JSON result. */\n"
" return JSON.stringify({\n"
" snapshot: treeLines.join('\\\\n'),\n"
" refs: refMap,\n"
" refCount: refCount\n"
" });\n"
"})\n";
/* ── Snapshot API ─────────────────────────────────────────────────── */
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact) {
if (webview == NULL) return NULL;
/* Build the JS call with arguments. Use g_strconcat instead of
* g_strdup_printf to avoid % format specifier issues in the JS. */
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
char *result = agent_js_eval_sync(webview, full_script, 15000);
g_free(full_script);
if (result == NULL) {
return NULL;
}
cJSON *json = cJSON_Parse(result);
g_free(result);
return json;
}
/* ── Async snapshot ───────────────────────────────────────────────── */
/* Handler that transforms the snapshot JS result into a cJSON response. */
static cJSON *snapshot_result_handler(const char *js_result) {
if (js_result == NULL || js_result[0] == '\0') {
return make_error_resp(0, "SNAPSHOT_FAILED", "Snapshot returned empty result");
}
cJSON *json = cJSON_Parse(js_result);
if (json == NULL) {
return make_error_resp(0, "SNAPSHOT_FAILED", "Failed to parse snapshot JSON");
}
return make_success_resp(0, json);
}
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id) {
if (webview == NULL) return FALSE;
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
gboolean started = agent_js_eval_async(webview, full_script, conn,
request_id, "snapshot",
snapshot_result_handler);
g_free(full_script);
return started;
}
+93
View File
@@ -0,0 +1,93 @@
/*
* agent_snapshot.h — accessibility tree snapshot for agent tools
*
* Injects a JavaScript script into the active tab's webview that walks
* the DOM, assigns sequential refs (e1, e2, ...) to interactive elements,
* and returns a text representation of the accessibility tree.
*/
#ifndef AGENT_SNAPSHOT_H
#define AGENT_SNAPSHOT_H
#include <webkit2/webkit2.h>
#include <libsoup/soup.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* The JS script injected for snapshot. It walks the DOM, assigns refs,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
extern const char *AGENT_SNAPSHOT_JS;
/*
* Take a snapshot of the given webview. This injects the snapshot JS
* and returns the result as a cJSON object:
* {"snapshot": "- heading ... [ref=e1]\n- link ... [ref=e2]",
* "refs": {"e1": {"role":"heading","name":"..."}, ...}}
*
* Returns NULL on failure. Caller frees the result.
*
* interactive: if TRUE, only include interactive elements
* compact: if TRUE, remove empty structural elements
*/
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact);
/*
* Async snapshot — starts JS evaluation and sends the response through
* the WebSocket connection when the JS completes. Returns TRUE if the
* async evaluation was started (response will be sent async), FALSE if
* it failed immediately (caller should send an error response).
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
*/
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id);
/*
* Async JS evaluation — evaluates a script and sends the result as a
* tool response through the WebSocket connection when the JS completes.
* Returns TRUE if started async, FALSE on immediate failure.
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
* tool_name: the tool name (for logging)
* success_handler: optional callback to transform the JS result into
* a cJSON response before sending. If NULL, the raw
* JS result string is returned in data.result.
*/
typedef cJSON *(*js_result_handler_t)(const char *js_result);
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler);
/*
* Execute JavaScript in the webview and wait for the result.
* This is a synchronous wrapper around the async evaluate_javascript API.
* Returns the result string (caller must free) or NULL on failure/timeout.
*
* script: the JavaScript to execute (must return a string)
* timeout_ms: how long to wait for the result
*/
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SNAPSHOT_H */
+5813
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
/*
* agent_tools.h — tool dispatch for agent browser automation
*
* Receives a JSON tool request and dispatches it to the appropriate
* tool implementation. Returns a JSON response.
*/
#ifndef AGENT_TOOLS_H
#define AGENT_TOOLS_H
#include "cjson/cJSON.h"
#include <libsoup/soup.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*.
*
* If the tool needs async JS evaluation, the response is sent directly
* through the WebSocket connection and this function returns NULL.
* Otherwise, the response is returned synchronously.
*
* conn: the WebSocket connection to send async responses through
* (may be NULL for testing without a real connection)
*
* Request format: {"id":1,"tool":"snapshot","params":{...}}
* Response format: {"id":1,"success":true,"data":{...}}
* or: {"id":1,"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_TOOLS_H */
+587
View File
@@ -0,0 +1,587 @@
/*
* bookmarks.c — NIP-44 encrypted Nostr bookmarks with directories
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
* and stored in the event's content field as a JSON tag array:
*
* [["bookmark", "https://example.com", "Example", 1690000000], ...]
*
* The encrypted event is published to the bootstrap relays and stored in
* the local SQLite database. On startup, the relay fetch retrieves kind
* 30003 events, which are decrypted and loaded into the in-memory list.
*/
#include "bookmarks.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.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;
static bookmark_dir_t *g_dirs = NULL;
static int g_dir_count = 0;
static int g_dir_cap = 0;
/* ── Helpers ───────────────────────────────────────────────────────── */
/* Find a directory by name. Returns the index, or -1 if not found. */
static int find_dir(const char *name) {
if (name == NULL) return -1;
for (int i = 0; i < g_dir_count; i++) {
if (strcmp(g_dirs[i].name, name) == 0) return i;
}
return -1;
}
/* Ensure a directory exists in the in-memory list. Returns the index. */
static int ensure_dir(const char *name) {
int idx = find_dir(name);
if (idx >= 0) return idx;
/* Grow the array if needed. */
if (g_dir_count >= g_dir_cap) {
int new_cap = g_dir_cap == 0 ? 8 : g_dir_cap * 2;
bookmark_dir_t *new_arr = g_realloc(g_dirs, new_cap * sizeof(*new_arr));
if (new_arr == NULL) return -1;
g_dirs = new_arr;
g_dir_cap = new_cap;
}
idx = g_dir_count++;
memset(&g_dirs[idx], 0, sizeof(g_dirs[idx]));
snprintf(g_dirs[idx].name, sizeof(g_dirs[idx].name), "%s", name);
return idx;
}
/* Free the bookmarks in a directory (but not the directory struct itself). */
static void free_dir_bookmarks(bookmark_dir_t *dir) {
if (dir->bookmarks == NULL) return;
for (int i = 0; i < dir->count; i++) {
g_free(dir->bookmarks[i].url);
g_free(dir->bookmarks[i].title);
}
g_free(dir->bookmarks);
dir->bookmarks = NULL;
dir->count = 0;
}
/* Find a bookmark in a directory by URL. Returns the index, or -1. */
static int find_bookmark_in_dir(const bookmark_dir_t *dir, const char *url) {
if (dir == NULL || url == NULL) return -1;
for (int i = 0; i < dir->count; i++) {
if (strcmp(dir->bookmarks[i].url, url) == 0) return i;
}
return -1;
}
/* ── Serialization ─────────────────────────────────────────────────── */
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON.
* Returns a newly allocated string (caller must g_free). */
static char *dir_to_json(const bookmark_dir_t *dir) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < dir->count; i++) {
cJSON *tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("bookmark"));
cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].url));
cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].title));
cJSON_AddItemToArray(tag,
cJSON_CreateNumber(dir->bookmarks[i].added));
cJSON_AddItemToArray(arr, tag);
}
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return json;
}
/* Parse a NIP-51 tag-array JSON string into a directory's bookmarks.
* Replaces the directory's current bookmark list. */
static int dir_load_from_json(bookmark_dir_t *dir, const char *json) {
if (dir == NULL || json == NULL) return -1;
cJSON *arr = cJSON_Parse(json);
if (arr == NULL) return -1;
if (!cJSON_IsArray(arr)) {
cJSON_Delete(arr);
return -1;
}
/* Free existing bookmarks. */
free_dir_bookmarks(dir);
int count = cJSON_GetArraySize(arr);
if (count > BOOKMARKS_MAX_PER_DIR) count = BOOKMARKS_MAX_PER_DIR;
dir->bookmarks = g_new0(bookmark_t, count);
dir->count = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, arr) {
if (dir->count >= count) break;
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
cJSON *t2 = cJSON_GetArrayItem(tag, 2);
cJSON *t3 = cJSON_GetArrayItem(tag, 3);
if (!cJSON_IsString(t0) || strcmp(t0->valuestring, "bookmark") != 0)
continue;
if (!cJSON_IsString(t1)) continue;
dir->bookmarks[dir->count].url = g_strdup(t1->valuestring);
dir->bookmarks[dir->count].title = g_strdup(
(t2 && cJSON_IsString(t2)) ? t2->valuestring : "");
dir->bookmarks[dir->count].added =
(t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0;
dir->count++;
}
cJSON_Delete(arr);
return 0;
}
/* ── NIP-44 encryption / decryption ────────────────────────────────── */
/* 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("[bookmarks] 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("[bookmarks] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* ── Publish ───────────────────────────────────────────────────────── */
/* 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;
}
/* Publish a kind 30003 event for a directory.
* Encrypts the directory's bookmarks, signs, publishes to relays,
* and stores in SQLite. Returns 0 on success, -1 on error. */
static int publish_directory(const char *dir_name) {
if (!g_have_signer) {
g_printerr("[bookmarks] No signer, cannot publish\n");
return -1;
}
int idx = find_dir(dir_name);
if (idx < 0) {
g_printerr("[bookmarks] Directory '%s' not found\n", dir_name);
return -1;
}
/* Serialize and encrypt. */
char *json = dir_to_json(&g_dirs[idx]);
if (json == NULL) return -1;
char *ciphertext = encrypt_content(json);
g_free(json);
if (ciphertext == NULL) return -1;
/* Build tags: [["d", dir_name]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(dir_name));
cJSON_AddItemToArray(tags, d_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
30003, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[bookmarks] Failed to create/sign event\n");
cJSON_Delete(tags);
return -1;
}
/* Store in SQLite. */
db_store_event(event);
/* 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("[bookmarks] Published directory '%s' to %d/%d relays\n",
dir_name, success_count, relay_count);
} else {
g_print("[bookmarks] No relays configured, event stored locally only\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}
/* ── Public API ────────────────────────────────────────────────────── */
int bookmarks_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';
}
/* Load cached kind 30003 events from SQLite. */
if (g_pubkey[0] != '\0') {
/* We need to query all kind 30003 events for this pubkey.
* db_get_events returns an array of events. */
cJSON *events = db_get_events(g_pubkey, 30003, 0);
if (events != NULL) {
int n = cJSON_GetArraySize(events);
g_print("[bookmarks] Loading %d cached directory event(s)\n", n);
cJSON *event;
cJSON_ArrayForEach(event, events) {
/* Get the d tag to find the directory name. */
const char *dir_name = "General";
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
if (cJSON_IsString(t0) &&
strcmp(t0->valuestring, "d") == 0) {
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t1)) {
dir_name = t1->valuestring;
}
break;
}
}
}
/* Decrypt the content. */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event,
"content");
if (cJSON_IsString(content) && content->valuestring[0]) {
char *plaintext = decrypt_content(content->valuestring);
if (plaintext) {
int idx = ensure_dir(dir_name);
if (idx >= 0) {
dir_load_from_json(&g_dirs[idx], plaintext);
}
free(plaintext);
}
}
}
cJSON_Delete(events);
}
}
/* Ensure "General" always exists. */
ensure_dir("General");
g_print("[bookmarks] Initialized: %d directory/ies, signer=%s\n",
g_dir_count, g_have_signer ? "yes" : "no");
return 0;
}
void bookmarks_cleanup(void) {
if (g_dirs) {
for (int i = 0; i < g_dir_count; i++) {
free_dir_bookmarks(&g_dirs[i]);
}
g_free(g_dirs);
g_dirs = NULL;
}
g_dir_count = 0;
g_dir_cap = 0;
g_signer = NULL;
g_have_signer = 0;
g_pubkey[0] = '\0';
}
const bookmark_dir_t *bookmarks_get_dirs(int *count_out) {
if (count_out) *count_out = g_dir_count;
return g_dirs;
}
const bookmark_dir_t *bookmarks_get_dir(const char *name) {
int idx = find_dir(name);
return (idx >= 0) ? &g_dirs[idx] : NULL;
}
int bookmarks_add(const char *dir, const char *url, const char *title) {
if (!g_have_signer) {
g_printerr("[bookmarks] No signer, cannot add\n");
return -1;
}
if (dir == NULL) dir = "General";
if (url == NULL || url[0] == '\0') return -1;
int idx = ensure_dir(dir);
if (idx < 0) return -1;
/* Check if already exists. */
if (find_bookmark_in_dir(&g_dirs[idx], url) >= 0) {
g_print("[bookmarks] URL already bookmarked in '%s'\n", dir);
return 0;
}
/* Check capacity. */
if (g_dirs[idx].count >= BOOKMARKS_MAX_PER_DIR) {
g_printerr("[bookmarks] Directory '%s' is full\n", dir);
return -1;
}
/* Add to in-memory list. */
int n = g_dirs[idx].count;
g_dirs[idx].bookmarks = g_realloc(g_dirs[idx].bookmarks,
(n + 1) * sizeof(bookmark_t));
g_dirs[idx].bookmarks[n].url = g_strdup(url);
g_dirs[idx].bookmarks[n].title = g_strdup(title ? title : "");
g_dirs[idx].bookmarks[n].added = (long)time(NULL);
g_dirs[idx].count++;
g_print("[bookmarks] Added '%s' to '%s'\n", url, dir);
/* Publish. */
return publish_directory(dir);
}
int bookmarks_remove(const char *dir, const char *url) {
if (!g_have_signer) return -1;
if (dir == NULL) dir = "General";
int idx = find_dir(dir);
if (idx < 0) return -1;
int bidx = find_bookmark_in_dir(&g_dirs[idx], url);
if (bidx < 0) return -1;
/* Free the bookmark. */
g_free(g_dirs[idx].bookmarks[bidx].url);
g_free(g_dirs[idx].bookmarks[bidx].title);
/* Shift remaining down. */
for (int i = bidx; i < g_dirs[idx].count - 1; i++) {
g_dirs[idx].bookmarks[i] = g_dirs[idx].bookmarks[i + 1];
}
g_dirs[idx].count--;
g_print("[bookmarks] Removed '%s' from '%s'\n", url, dir);
return publish_directory(dir);
}
int bookmarks_move(const char *from_dir, const char *url, const char *to_dir) {
if (!g_have_signer) return -1;
if (from_dir == NULL) from_dir = "General";
if (to_dir == NULL) to_dir = "General";
int from_idx = find_dir(from_dir);
if (from_idx < 0) return -1;
int bidx = find_bookmark_in_dir(&g_dirs[from_idx], url);
if (bidx < 0) return -1;
/* Save the bookmark data. */
char *saved_url = g_strdup(g_dirs[from_idx].bookmarks[bidx].url);
char *saved_title = g_strdup(g_dirs[from_idx].bookmarks[bidx].title);
long saved_added = g_dirs[from_idx].bookmarks[bidx].added;
/* Remove from source. */
g_free(g_dirs[from_idx].bookmarks[bidx].url);
g_free(g_dirs[from_idx].bookmarks[bidx].title);
for (int i = bidx; i < g_dirs[from_idx].count - 1; i++) {
g_dirs[from_idx].bookmarks[i] = g_dirs[from_idx].bookmarks[i + 1];
}
g_dirs[from_idx].count--;
/* Add to destination. */
int to_idx = ensure_dir(to_dir);
if (to_idx < 0) {
g_free(saved_url);
g_free(saved_title);
return -1;
}
int n = g_dirs[to_idx].count;
g_dirs[to_idx].bookmarks = g_realloc(g_dirs[to_idx].bookmarks,
(n + 1) * sizeof(bookmark_t));
g_dirs[to_idx].bookmarks[n].url = saved_url;
g_dirs[to_idx].bookmarks[n].title = saved_title;
g_dirs[to_idx].bookmarks[n].added = saved_added;
g_dirs[to_idx].count++;
g_print("[bookmarks] Moved '%s' from '%s' to '%s'\n",
url, from_dir, to_dir);
/* Publish both directories. */
publish_directory(from_dir);
return publish_directory(to_dir);
}
int bookmarks_create_dir(const char *name) {
if (!g_have_signer) return -1;
if (name == NULL || name[0] == '\0') return -1;
if (find_dir(name) >= 0) {
g_printerr("[bookmarks] Directory '%s' already exists\n", name);
return -1;
}
int idx = ensure_dir(name);
if (idx < 0) return -1;
g_print("[bookmarks] Created directory '%s'\n", name);
return publish_directory(name);
}
int bookmarks_delete_dir(const char *name, int move_to_general) {
if (!g_have_signer) return -1;
if (name == NULL) return -1;
if (strcmp(name, "General") == 0) {
g_printerr("[bookmarks] Cannot delete 'General'\n");
return -1;
}
int idx = find_dir(name);
if (idx < 0) return -1;
/* Optionally move bookmarks to General. */
if (move_to_general && g_dirs[idx].count > 0) {
int gen_idx = ensure_dir("General");
if (gen_idx >= 0) {
for (int i = 0; i < g_dirs[idx].count; i++) {
int n = g_dirs[gen_idx].count;
g_dirs[gen_idx].bookmarks = g_realloc(
g_dirs[gen_idx].bookmarks,
(n + 1) * sizeof(bookmark_t));
g_dirs[gen_idx].bookmarks[n] = g_dirs[idx].bookmarks[i];
g_dirs[gen_idx].count++;
}
g_dirs[idx].count = 0; /* Don't free — ownership moved */
g_dirs[idx].bookmarks = NULL;
publish_directory("General");
}
}
/* Free the directory's data. */
free_dir_bookmarks(&g_dirs[idx]);
/* Remove from the array. */
for (int i = idx; i < g_dir_count - 1; i++) {
g_dirs[i] = g_dirs[i + 1];
}
g_dir_count--;
g_print("[bookmarks] Deleted directory '%s'\n", name);
/* TODO: publish a kind 5 deletion event for the old d-tag.
* For now, the directory event will remain on relays but won't
* appear in the UI. A future fetch will re-add it — we need the
* deletion event to prevent that. */
return 0;
}
int bookmarks_load_dir_from_json(const char *dir_name, const char *json) {
if (dir_name == NULL || json == NULL) return -1;
int idx = ensure_dir(dir_name);
if (idx < 0) return -1;
return dir_load_from_json(&g_dirs[idx], json);
}
int bookmarks_store_and_load_event(const void *event_cjson) {
const cJSON *event = (const cJSON *)event_cjson;
if (event == NULL) return -1;
/* Store in SQLite. */
db_store_event(event);
/* If we have a signer, decrypt and load into memory. */
if (!g_have_signer) return 0;
/* Get the d tag. */
const char *dir_name = "General";
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0) {
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t1)) dir_name = t1->valuestring;
break;
}
}
}
/* Decrypt content. */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
char *plaintext = decrypt_content(content->valuestring);
if (plaintext) {
int idx = ensure_dir(dir_name);
if (idx >= 0) {
dir_load_from_json(&g_dirs[idx], plaintext);
}
free(plaintext);
}
}
return 0;
}
+106
View File
@@ -0,0 +1,106 @@
/*
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
* and stored in the event's content field as a JSON tag array.
*
* The bookmarks sync across all devices the user logs in on via the
* bootstrap relays.
*/
#ifndef BOOKMARKS_H
#define BOOKMARKS_H
#include <glib.h>
#include <time.h>
/* nostr_signer_t is needed for the init function */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
typedef struct {
char *url;
char *title;
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/*
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
* database and decrypts them using the signer.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars)
*
* Returns 0 on success, -1 on error.
*/
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
/* Free the in-memory bookmark list. Call at shutdown. */
void bookmarks_cleanup(void);
/* ── Read access ───────────────────────────────────────────────────── */
/* Get the list of directories (read-only). Returns a pointer to the
* internal array (valid until the next bookmarks operation). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get a specific directory by name. Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* ── Write access (requires signer) ────────────────────────────────── */
/* Add a bookmark to a directory. If the directory doesn't exist, it's
* created. Updates the in-memory list, re-encrypts, publishes a new
* kind 30003 event for that directory, and stores in SQLite.
*
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
/* Move a bookmark from one directory to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
/* Create a new empty directory. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if directory already exists / error. */
int bookmarks_create_dir(const char *name);
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
* to "General" before deletion. Publishes a kind 5 deletion event.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
/* ── Internal (used by relay fetch) ────────────────────────────────── */
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called by bookmarks_init and after relay fetch. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Store a raw kind 30003 event in the database and decrypt its content
* into the in-memory list. Called by the relay fetch after login. */
int bookmarks_store_and_load_event(const void *event_cjson);
#ifdef __cplusplus
}
#endif
#endif /* BOOKMARKS_H */
+474
View File
@@ -0,0 +1,474 @@
/*
* cli.c — command-line flag parsing for sovereign_browser
*
* Uses getopt_long to parse flags before gtk_init(). Recognized flags
* are stripped from argv (getopt_long reorders argv in place; optind
* points at the first positional arg on return). We collect positional
* args as startup URLs.
*
* Login flags build a cJSON params object matching the shape documented
* in agent_login.h and call agent_login(), reusing the same code path
* as the MCP 'login' tool.
*/
#define _POSIX_C_SOURCE 200809L /* strdup, getopt_long */
#include "cli.h"
#include "version.h"
#include "agent_login.h"
#include "key_store.h"
#include "nostr_core/nostr_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <getopt.h>
/* ── Usage text ────────────────────────────────────────────────────── */
void cli_print_usage(FILE *fp) {
fprintf(fp,
"sovereign_browser %s — WebKitGTK + C99\n"
"\n"
"Usage: sovereign_browser [OPTIONS] [URL...]\n"
"\n"
"Positional URLs are opened in tabs at startup (unless a session is\n"
"restored). --url flags are equivalent and may be repeated.\n"
"\n"
"Browser / Startup:\n"
" --url <url> Open URL in a tab (repeatable)\n"
" --new-tab-url <url> Override the default new-tab URL\n"
" --no-session-restore Skip session restore for this run\n"
" --session-restore Force session restore even if disabled\n"
" --max-tabs <n> Override max simultaneous tabs\n"
" -V, --version Print version and exit\n"
" -h, --help Show this help and exit\n"
"\n"
"Agent Server:\n"
" --port <port> Override agent MCP server port\n"
" --no-agent Disable the agent server for this run\n"
" --agent Force-enable the agent server\n"
" --agent-origin <origin> Allowed origin (repeatable)\n"
"\n"
"Login (skips the GTK login dialog):\n"
" --no-login Browse without a Nostr identity (normal browser mode)\n"
" --login-method <m> Method: generate|local|seed|readonly|nip46|nsigner\n"
" --nsec <nsec1...> (local) nsec bech32 private key\n"
" --privkey <hex> (local) 64-char hex private key\n"
" --mnemonic <words> (seed) BIP-39 mnemonic, quoted\n"
" --account <n> (seed) BIP-44 account index (default 0)\n"
" --npub <npub1...> (readonly) npub bech32 public key\n"
" --pubkey <hex> (readonly) 64-char hex pubkey\n"
" --bunker <url> (nip46) bunker:// URL\n"
" --nsigner-transport <t> (nsigner) serial|unix|tcp|qrexec\n"
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
" --nsigner-service <name> (nsigner) qrexec service name\n"
" --nsigner-index <n> (nsigner) key index (default 0)\n"
" --login-timeout <ms> Override agent login timeout (GTK dialog path)\n"
"\n"
"Diagnostics:\n"
" -v, --verbose Increase log verbosity (repeatable)\n"
" -q, --quiet Suppress non-error log output\n"
"\n"
"Examples:\n"
" sovereign_browser https://example.com\n"
" sovereign_browser --login-method generate --url https://example.com\n"
" sovereign_browser --login-method local --nsec nsec1... --port 18888\n"
" sovereign_browser --login-method readonly --npub npub1... --no-agent\n"
" sovereign_browser --no-agent --url https://a.com --url https://b.com\n",
SB_VERSION);
}
/* ── getopt_long option table ──────────────────────────────────────── */
enum {
OPT_URL = 1000,
OPT_NEW_TAB_URL,
OPT_NO_SESSION_RESTORE,
OPT_SESSION_RESTORE,
OPT_MAX_TABS,
OPT_VERSION,
OPT_PORT,
OPT_NO_AGENT,
OPT_AGENT,
OPT_AGENT_ORIGIN,
OPT_LOGIN_METHOD,
OPT_NSEC,
OPT_PRIVKEY,
OPT_MNEMONIC,
OPT_ACCOUNT,
OPT_NPUB,
OPT_PUBKEY,
OPT_BUNKER,
OPT_NSIGNER_TRANSPORT,
OPT_NSIGNER_DEVICE,
OPT_NSIGNER_SERVICE,
OPT_NSIGNER_INDEX,
OPT_NO_LOGIN,
OPT_LOGIN_TIMEOUT,
OPT_VERBOSE,
OPT_QUIET,
OPT_HELP,
};
static struct option long_opts[] = {
/* Browser / startup */
{"url", required_argument, 0, OPT_URL},
{"new-tab-url", required_argument, 0, OPT_NEW_TAB_URL},
{"no-session-restore", no_argument, 0, OPT_NO_SESSION_RESTORE},
{"session-restore", no_argument, 0, OPT_SESSION_RESTORE},
{"max-tabs", required_argument, 0, OPT_MAX_TABS},
{"version", no_argument, 0, OPT_VERSION},
/* Agent server */
{"port", required_argument, 0, OPT_PORT},
{"no-agent", no_argument, 0, OPT_NO_AGENT},
{"agent", no_argument, 0, OPT_AGENT},
{"agent-origin", required_argument, 0, OPT_AGENT_ORIGIN},
/* Login */
{"login-method", required_argument, 0, OPT_LOGIN_METHOD},
{"nsec", required_argument, 0, OPT_NSEC},
{"privkey", required_argument, 0, OPT_PRIVKEY},
{"mnemonic", required_argument, 0, OPT_MNEMONIC},
{"account", required_argument, 0, OPT_ACCOUNT},
{"npub", required_argument, 0, OPT_NPUB},
{"pubkey", required_argument, 0, OPT_PUBKEY},
{"bunker", required_argument, 0, OPT_BUNKER},
{"nsigner-transport", required_argument, 0, OPT_NSIGNER_TRANSPORT},
{"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE},
{"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE},
{"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX},
{"no-login", no_argument, 0, OPT_NO_LOGIN},
{"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT},
/* Diagnostics */
{"verbose", no_argument, 0, OPT_VERBOSE},
{"quiet", no_argument, 0, OPT_QUIET},
{"help", no_argument, 0, OPT_HELP},
{0, 0, 0, 0}
};
static const char *short_opts = "vVhq";
/* ── Helpers ───────────────────────────────────────────────────────── */
static char *xstrdup(const char *s) {
if (s == NULL) return NULL;
char *p = strdup(s);
if (p == NULL) {
fprintf(stderr, "[cli] out of memory\n");
exit(EXIT_FAILURE);
}
return p;
}
static void add_string(char ***arr, int *count, const char *value) {
*arr = g_realloc(*arr, sizeof(char *) * (*count + 1));
(*arr)[*count] = xstrdup(value);
(*count)++;
}
static int valid_login_method(const char *m) {
return strcmp(m, "generate") == 0 ||
strcmp(m, "local") == 0 ||
strcmp(m, "seed") == 0 ||
strcmp(m, "readonly") == 0 ||
strcmp(m, "nip46") == 0 ||
strcmp(m, "nsigner") == 0;
}
/* ── cli_args_free ─────────────────────────────────────────────────── */
void cli_args_free(cli_args_t *args) {
if (args == NULL) return;
if (args->urls) {
for (int i = 0; i < args->url_count; i++) g_free(args->urls[i]);
g_free(args->urls);
}
g_free(args->new_tab_url);
if (args->agent_origins) {
for (int i = 0; i < args->agent_origin_count; i++) g_free(args->agent_origins[i]);
g_free(args->agent_origins);
}
g_free(args->login_method);
g_free(args->nsec);
g_free(args->privkey);
g_free(args->mnemonic);
g_free(args->npub);
g_free(args->pubkey);
g_free(args->bunker);
g_free(args->nsigner_transport);
g_free(args->nsigner_device);
g_free(args->nsigner_service);
memset(args, 0, sizeof(*args));
}
/* ── cli_parse ─────────────────────────────────────────────────────── */
int cli_parse(int *argc, char ***argv, cli_args_t *out) {
memset(out, 0, sizeof(*out));
out->account = -1;
out->nsigner_index = -1;
/* getopt_long reorders argv so non-options end up at the end. */
opterr = 0; /* we print our own errors */
optind = 1;
int c;
int long_idx;
while ((c = getopt_long(*argc, *argv, short_opts, long_opts, &long_idx)) != -1) {
switch (c) {
/* Short options */
case 'v': out->verbose++; break;
case 'V': out->want_version = TRUE; return 0;
case 'h': out->want_help = TRUE; return 0;
case 'q': out->quiet = TRUE; break;
case '?':
if (optopt) {
fprintf(stderr, "[cli] unknown short option '-%c'\n", optopt);
} else {
fprintf(stderr, "[cli] unknown long option near '%s'\n",
(*argv)[optind - 1] ? (*argv)[optind - 1] : "?");
}
cli_print_usage(stderr);
return -1;
/* Browser / startup */
case OPT_URL:
add_string(&out->urls, &out->url_count, optarg);
break;
case OPT_NEW_TAB_URL:
g_free(out->new_tab_url);
out->new_tab_url = xstrdup(optarg);
break;
case OPT_NO_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_FALSE;
break;
case OPT_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_TRUE;
break;
case OPT_MAX_TABS:
out->max_tabs = atoi(optarg);
if (out->max_tabs < 1) {
fprintf(stderr, "[cli] --max-tabs must be >= 1\n");
return -1;
}
break;
case OPT_VERSION:
out->want_version = TRUE;
return 0;
/* Agent server */
case OPT_PORT:
out->agent_port = atoi(optarg);
if (out->agent_port < 1 || out->agent_port > 65535) {
fprintf(stderr, "[cli] --port must be 1-65535\n");
return -1;
}
break;
case OPT_NO_AGENT:
out->agent_enabled = CLI_TRISTATE_FALSE;
break;
case OPT_AGENT:
out->agent_enabled = CLI_TRISTATE_TRUE;
break;
case OPT_AGENT_ORIGIN:
add_string(&out->agent_origins, &out->agent_origin_count, optarg);
break;
/* Login */
case OPT_LOGIN_METHOD:
if (!valid_login_method(optarg)) {
fprintf(stderr, "[cli] invalid --login-method '%s'\n", optarg);
fprintf(stderr, "[cli] use: generate|local|seed|readonly|nip46|nsigner\n");
return -1;
}
g_free(out->login_method);
out->login_method = xstrdup(optarg);
break;
case OPT_NSEC:
g_free(out->nsec);
out->nsec = xstrdup(optarg);
break;
case OPT_PRIVKEY:
g_free(out->privkey);
out->privkey = xstrdup(optarg);
break;
case OPT_MNEMONIC:
g_free(out->mnemonic);
out->mnemonic = xstrdup(optarg);
break;
case OPT_ACCOUNT:
out->account = atoi(optarg);
if (out->account < 0) {
fprintf(stderr, "[cli] --account must be >= 0\n");
return -1;
}
break;
case OPT_NPUB:
g_free(out->npub);
out->npub = xstrdup(optarg);
break;
case OPT_PUBKEY:
g_free(out->pubkey);
out->pubkey = xstrdup(optarg);
break;
case OPT_BUNKER:
g_free(out->bunker);
out->bunker = xstrdup(optarg);
break;
case OPT_NSIGNER_TRANSPORT:
g_free(out->nsigner_transport);
out->nsigner_transport = xstrdup(optarg);
break;
case OPT_NSIGNER_DEVICE:
g_free(out->nsigner_device);
out->nsigner_device = xstrdup(optarg);
break;
case OPT_NSIGNER_SERVICE:
g_free(out->nsigner_service);
out->nsigner_service = xstrdup(optarg);
break;
case OPT_NSIGNER_INDEX:
out->nsigner_index = atoi(optarg);
if (out->nsigner_index < 0) {
fprintf(stderr, "[cli] --nsigner-index must be >= 0\n");
return -1;
}
break;
case OPT_NO_LOGIN:
out->no_login = TRUE;
break;
case OPT_LOGIN_TIMEOUT:
out->login_timeout_ms = atoi(optarg);
if (out->login_timeout_ms < 0) {
fprintf(stderr, "[cli] --login-timeout must be >= 0\n");
return -1;
}
break;
/* Diagnostics */
case OPT_VERBOSE:
out->verbose++;
break;
case OPT_QUIET:
out->quiet = TRUE;
break;
case OPT_HELP:
out->want_help = TRUE;
return 0;
default:
fprintf(stderr, "[cli] unhandled option\n");
return -1;
}
}
/* Validate login-method-specific args. */
if (out->login_method) {
const char *m = out->login_method;
if (strcmp(m, "local") == 0) {
if (!out->nsec && !out->privkey) {
fprintf(stderr, "[cli] --login-method local requires --nsec or --privkey\n");
return -1;
}
} else if (strcmp(m, "seed") == 0) {
if (!out->mnemonic) {
fprintf(stderr, "[cli] --login-method seed requires --mnemonic\n");
return -1;
}
} else if (strcmp(m, "readonly") == 0) {
if (!out->npub && !out->pubkey) {
fprintf(stderr, "[cli] --login-method readonly requires --npub or --pubkey\n");
return -1;
}
} else if (strcmp(m, "nip46") == 0) {
if (!out->bunker) {
fprintf(stderr, "[cli] --login-method nip46 requires --bunker\n");
return -1;
}
} else if (strcmp(m, "nsigner") == 0) {
if (!out->nsigner_transport || !out->nsigner_device) {
fprintf(stderr, "[cli] --login-method nsigner requires --nsigner-transport and --nsigner-device\n");
return -1;
}
}
/* "generate" needs no extra args. */
}
/* Collect positional args (after getopt_long reordering) as URLs. */
for (int i = optind; i < *argc; i++) {
add_string(&out->urls, &out->url_count, (*argv)[i]);
}
/* Repack argv: keep argv[0], then positional args only. gtk_init()
* will see a clean vector with no unknown options. */
int new_argc = 1; /* argv[0] */
for (int i = optind; i < *argc; i++) {
(*argv)[new_argc++] = (*argv)[i];
}
(*argv)[new_argc] = NULL;
*argc = new_argc;
return 0;
}
/* ── cli_login ─────────────────────────────────────────────────────── */
int cli_login(const cli_args_t *args) {
if (args->login_method == NULL) {
fprintf(stderr, "[cli] cli_login called with no --login-method\n");
return -1;
}
/* Build the cJSON params object matching agent_login.h's contract. */
cJSON *params = cJSON_CreateObject();
cJSON_AddStringToObject(params, "method", args->login_method);
if (args->nsec) cJSON_AddStringToObject(params, "nsec", args->nsec);
if (args->privkey) cJSON_AddStringToObject(params, "privkey_hex", args->privkey);
if (args->mnemonic) cJSON_AddStringToObject(params, "mnemonic", args->mnemonic);
if (args->account >= 0) cJSON_AddNumberToObject(params, "account", args->account);
if (args->npub) cJSON_AddStringToObject(params, "npub", args->npub);
if (args->pubkey) cJSON_AddStringToObject(params, "pubkey_hex", args->pubkey);
if (args->bunker) cJSON_AddStringToObject(params, "bunker_url", args->bunker);
if (args->nsigner_transport) cJSON_AddStringToObject(params, "transport", args->nsigner_transport);
if (args->nsigner_device) cJSON_AddStringToObject(params, "device", args->nsigner_device);
if (args->nsigner_service) cJSON_AddStringToObject(params, "service", args->nsigner_service);
if (args->nsigner_index >= 0) cJSON_AddNumberToObject(params, "index", args->nsigner_index);
/* agent_login() calls app_set_signer() on success, which sets the
* same global state the GTK dialog would. It also handles
* nostr_init() internally. */
cJSON *result = agent_login(params);
cJSON_Delete(params);
if (result == NULL) {
fprintf(stderr, "[cli] login returned NULL\n");
return -1;
}
cJSON *success = cJSON_GetObjectItem(result, "success");
if (!success || !cJSON_IsTrue(success)) {
cJSON *err = cJSON_GetObjectItem(result, "error");
const char *code = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "code")) : "?";
const char *msg = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "message")) : "unknown";
fprintf(stderr, "[cli] login failed: %s: %s\n", code ? code : "?", msg ? msg : "?");
cJSON_Delete(result);
return -1;
}
/* Login succeeded. Print the pubkey for visibility. */
cJSON *data = cJSON_GetObjectItem(result, "data");
if (data) {
const char *pubkey = cJSON_GetStringValue(cJSON_GetObjectItem(data, "pubkey"));
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(data, "method"));
g_print("[cli] Logged in: method=%s pubkey=%s\n",
method ? method : "?", pubkey ? pubkey : "?");
}
cJSON_Delete(result);
/* Private keys are never persisted to disk — they live only in RAM
* for the duration of the session. The identity is held in the
* global app_state_t (main.c) and is gone when the browser quits. */
return 0;
}
+122
View File
@@ -0,0 +1,122 @@
/*
* cli.h — command-line flag parsing for sovereign_browser
*
* Parses argv before gtk_init() so GTK doesn't abort on our flags.
* Recognized flags are stripped from argv/argc so gtk_init() only sees
* positional URLs. CLI flags override settings.conf for this run only
* (nothing is written to disk).
*
* Login flags build a cJSON params object and call agent_login(),
* reusing the same code path as the MCP 'login' tool. On success the
* GTK login dialog is skipped entirely.
*/
#ifndef CLI_H
#define CLI_H
#include <glib.h>
#include <stdio.h> /* FILE */
#ifdef __cplusplus
extern "C" {
#endif
/* Tri-state for boolean overrides. */
typedef enum {
CLI_TRISTATE_UNSET = 0,
CLI_TRISTATE_TRUE,
CLI_TRISTATE_FALSE
} cli_tristate_t;
/* Parsed command-line arguments. Zero-initialized by cli_parse().
* Strings are heap-allocated and owned by the cli_args_t — free with
* cli_args_free(). */
typedef struct {
/* Startup URLs: --url flags + positional args, in order. */
char **urls;
int url_count;
/* Browser overrides (NULL / 0 = not set). */
char *new_tab_url; /* --new-tab-url */
int max_tabs; /* --max-tabs (0 = unset) */
cli_tristate_t session_restore; /* --session-restore / --no-session-restore */
/* Agent server overrides. */
int agent_port; /* --port (0 = unset) */
cli_tristate_t agent_enabled; /* --agent / --no-agent */
char **agent_origins; /* --agent-origin (repeatable) */
int agent_origin_count;
/* Login. login_method == NULL means "show GTK dialog".
* no_login == TRUE means skip login entirely (browse without identity). */
gboolean no_login; /* --no-login */
char *login_method; /* --login-method */
char *nsec; /* --nsec */
char *privkey; /* --privkey */
char *mnemonic; /* --mnemonic */
int account; /* --account (-1 = unset, defaults to 0) */
char *npub; /* --npub */
char *pubkey; /* --pubkey */
char *bunker; /* --bunker */
char *nsigner_transport; /* --nsigner-transport */
char *nsigner_device; /* --nsigner-device */
char *nsigner_service; /* --nsigner-service */
int nsigner_index; /* --nsigner-index (-1 = unset) */
int login_timeout_ms; /* --login-timeout (0 = unset) */
/* Diagnostics. */
int verbose; /* --verbose / -v (repeatable count) */
gboolean quiet; /* --quiet / -q */
/* Exit-request flags set by --help / --version. */
gboolean want_help;
gboolean want_version;
} cli_args_t;
/*
* Parse command-line arguments.
*
* On entry, *argc / *argv are the raw values from main(). On return,
* recognized flags are removed from *argv / *argc (repacked via
* getopt_long's optind) so the remaining vector is safe to pass to
* gtk_init(). Positional URL arguments are preserved.
*
* Returns:
* 0 — parse OK (or --help / --version requested; check args.want_*).
* -1 — parse error (message printed to stderr).
*
* On error or when want_help/want_version is true, the caller should
* exit without calling gtk_init().
*/
int cli_parse(int *argc, char ***argv, cli_args_t *out);
/*
* Free heap-allocated fields in a cli_args_t. Safe to call on a
* zero-initialized struct.
*/
void cli_args_free(cli_args_t *args);
/*
* Print usage text to the given stream.
*/
void cli_print_usage(FILE *fp);
/*
* Perform CLI login by building a cJSON params object from args and
* calling agent_login(). Requires nostr_init() to have been called
* already (agent_login() handles re-init internally).
*
* Returns:
* 0 — login succeeded (global app state is now set).
* -1 — login failed (error message printed to stderr).
*
* If args->no_save_identity is FALSE, the identity is persisted to
* ~/.sovereign_browser/identity.json via key_store_save() on success.
*/
int cli_login(const cli_args_t *args);
#ifdef __cplusplus
}
#endif
#endif /* CLI_H */
+647
View File
@@ -0,0 +1,647 @@
/*
* db.c — SQLite storage for sovereign_browser
*
* Stores Nostr events and misc data in ~/.sovereign_browser/browser.db.
* Uses SQLITE_OPEN_FULLMUTEX for thread safety (the relay fetch runs in
* a background thread).
*/
#include "db.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
/* ── Global state ──────────────────────────────────────────────────── */
static sqlite3 *g_db = NULL;
/* Static buffer for db_kv_get() — valid until the next call. */
static char g_kv_buf[4096];
/* ── Path helper ───────────────────────────────────────────────────── */
static int db_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/browser.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Schema ────────────────────────────────────────────────────────── */
static const char *SCHEMA_SQL =
"CREATE TABLE IF NOT EXISTS events ("
" id TEXT PRIMARY KEY,"
" pubkey TEXT NOT NULL,"
" kind INTEGER NOT NULL,"
" created_at INTEGER NOT NULL,"
" content TEXT,"
" sig TEXT,"
" raw_json TEXT NOT NULL,"
" fetched_at INTEGER NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS event_tags ("
" event_id TEXT NOT NULL,"
" tag_name TEXT NOT NULL,"
" tag_value TEXT,"
" position INTEGER NOT NULL,"
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
");"
"CREATE TABLE IF NOT EXISTS key_value ("
" key TEXT PRIMARY KEY,"
" value TEXT"
");"
"CREATE TABLE IF NOT EXISTS history ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" url TEXT NOT NULL UNIQUE,"
" title TEXT,"
" visited_at INTEGER NOT NULL,"
" visit_count INTEGER DEFAULT 1"
");"
"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,"
" title TEXT"
");"
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
" ON events(pubkey, kind, created_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
" ON event_tags(tag_name, tag_value);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
" ON event_tags(event_id);";
/* ── Init / Close ──────────────────────────────────────────────────── */
int db_init(void) {
char path[512];
if (db_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get database path\n");
return -1;
}
int rc = sqlite3_open_v2(path, &g_db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to open database: %s\n",
sqlite3_errmsg(g_db));
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
}
return -1;
}
/* Set busy timeout to 5 seconds (in case of contention). */
sqlite3_busy_timeout(g_db, 5000);
/* Enable foreign keys (for ON DELETE CASCADE on event_tags). */
char *err = NULL;
rc = sqlite3_exec(g_db, "PRAGMA foreign_keys = ON;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to enable foreign keys: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
}
/* Create schema. */
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to create schema: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
sqlite3_close(g_db);
g_db = NULL;
return -1;
}
g_print("[db] Database initialized at %s\n", path);
return 0;
}
void db_close(void) {
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
g_print("[db] Database closed\n");
}
}
/* ── Events ────────────────────────────────────────────────────────── */
/* Helper: get a string field from a cJSON event, or "" if missing. */
static const char *event_str(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsString(item)) return item->valuestring;
return "";
}
/* Helper: get an integer field from a cJSON event, or 0 if missing. */
static long event_int(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsNumber(item)) return (long)item->valuedouble;
return 0;
}
int db_store_event(const cJSON *event) {
if (g_db == NULL || event == NULL) return -1;
const char *id = event_str(event, "id");
const char *pubkey = event_str(event, "pubkey");
long kind = event_int(event, "kind");
long created = event_int(event, "created_at");
const char *content = event_str(event, "content");
const char *sig = event_str(event, "sig");
if (id[0] == '\0' || pubkey[0] == '\0') {
g_printerr("[db] Event missing id or pubkey\n");
return -1;
}
/* Serialize the full event to JSON for round-tripping. */
char *raw_json = cJSON_PrintUnformatted(event);
if (raw_json == NULL) return -1;
/* Begin transaction. */
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] BEGIN failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
/* Upsert the event (INSERT OR REPLACE). */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO events "
"(id, pubkey, kind, created_at, content, sig, raw_json, fetched_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'));",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, kind);
sqlite3_bind_int64(stmt, 4, created);
sqlite3_bind_text(stmt, 5, content, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, sig, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 7, raw_json, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] Events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
/* Delete old tags for this event (if replacing) and insert new ones. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Insert tags. */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
rc = sqlite3_prepare_v2(g_db,
"INSERT INTO event_tags (event_id, tag_name, tag_value, position) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
int pos = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name_item = cJSON_GetArrayItem(tag, 0);
cJSON *value_item = cJSON_GetArrayItem(tag, 1);
const char *tag_name = (name_item && cJSON_IsString(name_item))
? name_item->valuestring : "";
const char *tag_value = (value_item && cJSON_IsString(value_item))
? value_item->valuestring : "";
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, tag_name, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, tag_value, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 4, pos);
sqlite3_step(stmt);
sqlite3_reset(stmt);
pos++;
}
sqlite3_finalize(stmt);
}
}
/* Commit. */
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] COMMIT failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
free(raw_json);
return 0;
}
cJSON *db_get_latest_event(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare latest_event failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
cJSON *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
result = cJSON_Parse(json_str);
}
}
sqlite3_finalize(stmt);
return result;
}
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
/* If limit is 0, use a large default. */
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
sqlite3_bind_int(stmt, 3, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_count_events(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT COUNT(*) FROM events WHERE pubkey = ? AND kind = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
int count = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return count;
}
/* ── Key-Value store ───────────────────────────────────────────────── */
int db_kv_set(const char *key, const char *value) {
if (g_db == NULL || key == NULL || value == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
const char *db_kv_get(const char *key) {
if (g_db == NULL || key == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT value FROM key_value WHERE key = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return NULL;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
const char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *val = (const char *)sqlite3_column_text(stmt, 0);
if (val) {
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
result = g_kv_buf;
}
}
sqlite3_finalize(stmt);
return result;
}
char *db_kv_get_copy(const char *key) {
const char *val = db_kv_get(key);
if (val == NULL) return NULL;
return g_strdup(val);
}
/* ── History ───────────────────────────────────────────────────────── */
int db_history_add(const char *url, const char *title) {
if (g_db == NULL || url == NULL || url[0] == '\0') return -1;
/* UPSERT: insert or update visited_at + visit_count. */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO history (url, title, visited_at, visit_count) "
"VALUES (?, ?, strftime('%s','now'), 1) "
"ON CONFLICT(url) DO UPDATE SET "
" title = excluded.title, "
" visited_at = strftime('%s','now'), "
" visit_count = visit_count + 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
int db_history_get(char ***urls_out, char ***titles_out,
int *count_out, int limit) {
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
if (limit <= 0) limit = 50;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM history "
"ORDER BY visited_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_int(stmt, 1, limit);
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* 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;
}
int db_history_clear(void) {
if (g_db == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
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) {
if (g_db == NULL || urls == NULL) return -1;
/* Clear the session table first. */
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
/* Insert each tab. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
for (int i = 0; i < count; i++) {
if (urls[i] == NULL) continue;
sqlite3_bind_int(stmt, 1, i);
sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3,
(titles && titles[i]) ? titles[i] : "",
-1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_reset(stmt);
}
sqlite3_finalize(stmt);
return 0;
}
int db_session_load(char ***urls_out, char ***titles_out, int *count_out) {
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
/* Count rows. */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM session ORDER BY tab_index;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
if (count == 0) {
sqlite3_finalize(stmt);
*count_out = 0;
return 0;
}
*urls_out = g_new0(char *, count);
if (titles_out) *titles_out = g_new0(char *, count);
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);
*count_out = i;
return i;
}
int db_session_clear(void) {
if (g_db == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
+175
View File
@@ -0,0 +1,175 @@
/*
* db.h — SQLite storage for sovereign_browser
*
* Provides persistent local storage for Nostr events and misc data.
* The database lives at ~/.sovereign_browser/browser.db.
*
* Tables:
* events — Nostr events (id, pubkey, kind, created_at, content, sig, raw_json)
* event_tags — Tag rows for querying by tag name/value
* key_value — Simple key-value store for misc settings/cache
*
* Thread safety: the database is opened with SQLITE_OPEN_FULLMUTEX, so
* calls from multiple threads are safe (serialized by SQLite's mutex).
*/
#ifndef DB_H
#define DB_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables and indexes if they don't exist.
* Call once at startup (after settings_load()).
*
* Returns 0 on success, -1 on error.
*/
int db_init(void);
/*
* Close the database. Call at shutdown.
*/
void db_close(void);
/* ── Events ────────────────────────────────────────────────────────── */
/*
* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events + event_tags tables.
*
* event — a cJSON object representing a Nostr event with at least:
* id, pubkey, kind, created_at, content, sig, tags
*
* Returns 0 on success, -1 on error.
*/
int db_store_event(const cJSON *event);
/*
* Fetch the most recent event of a given kind for a pubkey.
*
* Returns a newly allocated cJSON event (parsed from raw_json), or NULL
* if not found. Caller must cJSON_Delete() the result.
*/
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/*
* Fetch all events of a given kind for a pubkey, newest first.
*
* limit — max number of events (0 = no limit)
*
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
* Returns NULL on error.
*/
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
/*
* Count events of a given kind for a pubkey.
* Returns the count, or -1 on error.
*/
int db_count_events(const char *pubkey_hex, int kind);
/* ── Key-Value store ───────────────────────────────────────────────── */
/*
* Set a key-value pair (upsert).
* Returns 0 on success, -1 on error.
*/
int db_kv_set(const char *key, const char *value);
/*
* Get a value by key.
* Returns a pointer to the value string, or NULL if not found.
* The pointer is valid until the next db_kv_get() call (uses a static
* buffer). Call db_kv_get_copy() if you need a persistent copy.
*/
const char *db_kv_get(const char *key);
/*
* Get a value by key, returning a newly allocated copy.
* Caller must g_free() the result. Returns NULL if not found.
*/
char *db_kv_get_copy(const char *key);
/* ── History ───────────────────────────────────────────────────────── */
/*
* Add a URL to the history (UPSERT — increments visit_count and updates
* visited_at if the URL already exists).
* url — the URL to record
* title — optional page title (NULL or "" for none)
* Returns 0 on success, -1 on error.
*/
int db_history_add(const char *url, const char *title);
/*
* Get recent history entries (most-recent-first).
* limit — max number of entries (0 = default 50)
*
* 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_get(char ***urls_out, char ***titles_out,
int *count_out, int limit);
/*
* Clear all history entries.
* Returns 0 on success, -1 on error.
*/
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 ───────────────────────────────────────────────────────── */
/*
* Save the current session (open tab URLs) to the database.
* Clears the session table first, then inserts the given URLs in order.
* urls — array of URL strings
* titles — array of title strings (can be NULL for no titles)
* count — number of tabs
* Returns 0 on success, -1 on error.
*/
int db_session_save(const char **urls, const char **titles, int count);
/*
* Load the saved session from the database.
* Fills urls_out and titles_out with parallel arrays (tab_index order).
* Caller must free each string and the arrays.
* Returns the number of tabs, or -1 on error / no session.
*/
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
/*
* Clear the saved session.
* Returns 0 on success, -1 on error.
*/
int db_session_clear(void);
#ifdef __cplusplus
}
#endif
#endif /* DB_H */
+87 -136
View File
@@ -1,160 +1,111 @@
/*
* history.c — URL history tracking for sovereign_browser
*
* History is stored in the SQLite database (history table) with timestamps
* and visit counts. No flat file, no entry cap.
*/
#include "history.h"
#include "db.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
/* ── In-memory history ─────────────────────────────────────────── */
static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN];
static int g_history_count = 0;
/* ── Path helper ───────────────────────────────────────────────── */
static int history_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/history.txt", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Persistence ───────────────────────────────────────────────── */
void history_load(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return;
}
g_history_count = 0;
char line[HISTORY_URL_MAX_LEN];
while (g_history_count < HISTORY_MAX_ENTRIES &&
fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0';
len--;
}
if (len > 0) {
snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line);
g_history_count++;
}
}
fclose(f);
}
static void history_save(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
/* Write in reverse order (most recent first) — same as in-memory order. */
for (int i = 0; i < g_history_count; i++) {
fprintf(f, "%s\n", g_history[i]);
}
fclose(f);
}
/* ── API ───────────────────────────────────────────────────────── */
/* ── Public API ────────────────────────────────────────────────────── */
void history_add(const char *url) {
if (url == NULL || url[0] == '\0') {
history_add_titled(url, NULL);
}
void history_add_titled(const char *url, const char *title) {
if (url == NULL || url[0] == '\0') return;
/* Skip sovereign:// API endpoints (not pages the user would want
* in their recents). The actual pages — sovereign://settings,
* sovereign://profile, sovereign://bookmarks, sovereign://security —
* are kept in history so the user can quickly return to them. */
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
strncmp(url, "sovereign://bookmarks/add", 25) == 0 ||
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
strncmp(url, "sovereign://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;
}
/* Skip sovereign:// internal pages. */
if (strncmp(url, "sovereign://", 12) == 0) {
return;
}
/* Check if the URL is already in the history. */
int found_idx = -1;
for (int i = 0; i < g_history_count; i++) {
if (strcmp(g_history[i], url) == 0) {
found_idx = i;
break;
}
}
if (found_idx >= 0) {
/* URL already exists — move it to the top. */
if (found_idx == 0) {
return; /* already at the top, nothing to do */
}
/* Shift items 0..found_idx-1 down by one. */
char tmp[HISTORY_URL_MAX_LEN];
snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]);
for (int i = found_idx; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp);
} else {
/* New URL — shift everything down by one to make room at the top. */
if (g_history_count >= HISTORY_MAX_ENTRIES) {
g_history_count = HISTORY_MAX_ENTRIES - 1;
}
for (int i = g_history_count; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url);
g_history_count++;
}
history_save();
db_history_add(url, title);
}
int history_count(void) {
return g_history_count;
/* Query the count from the database via db_history_get.
* For the Recents submenu we only show 20, but we need the
* total count to decide whether to show the submenu. */
char **urls = NULL;
int count = 0;
int rc = db_history_get(&urls, NULL, &count, 10000);
if (rc < 0) return 0;
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
return count;
}
const char *history_get(int index) {
if (index < 0 || index >= g_history_count) {
char *history_get(int index) {
if (index < 0) return NULL;
char **urls = NULL;
int count = 0;
db_history_get(&urls, NULL, &count, index + 1);
if (urls == NULL || count <= index) {
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
return NULL;
}
return g_history[index];
char *result = urls[index];
/* Free the other entries. */
for (int i = 0; i < count; i++) {
if (i != index) g_free(urls[i]);
}
g_free(urls);
return result;
}
char *history_get_title(int index) {
if (index < 0) return NULL;
char **urls = NULL;
char **titles = NULL;
int count = 0;
db_history_get(&urls, &titles, &count, index + 1);
if (urls == NULL || count <= index) {
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
if (titles) {
for (int i = 0; i < count; i++) g_free(titles[i]);
g_free(titles);
}
return NULL;
}
char *result = titles[index];
/* Free everything except the result. */
for (int i = 0; i < count; i++) {
if (i != index) g_free(titles[i]);
g_free(urls[i]);
}
g_free(urls);
g_free(titles);
return result;
}
void history_clear(void) {
g_history_count = 0;
char path[512];
if (history_path(path, sizeof(path)) == 0) {
unlink(path);
}
db_history_clear();
g_print("[history] cleared\n");
}
+17 -14
View File
@@ -1,8 +1,9 @@
/*
* history.h — URL history tracking for sovereign_browser
*
* Keeps a list of recently visited URLs (most recent first) and
* provides them for the History submenu.
* History is stored in the SQLite database (history table) with timestamps
* and visit counts. There is no entry cap — the database handles unlimited
* entries. The Recents submenu shows the most recent 20.
*/
#ifndef HISTORY_H
@@ -14,35 +15,37 @@
extern "C" {
#endif
#define HISTORY_MAX_ENTRIES 50
#define HISTORY_URL_MAX_LEN 2048
/*
* Add a URL to the history. If the URL is already the most recent
* entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES.
* Also persists to ~/.sovereign_browser/history.txt.
* Add a URL to the history. UPSERT — if the URL already exists,
* increments visit_count and updates visited_at. The title is optional
* (NULL or "" for no title).
*/
void history_add(const char *url);
void history_add_titled(const char *url, const char *title);
/*
* Get the number of history entries.
* Get the number of history entries available (queries the database).
* Returns the count, or -1 on error.
*/
int history_count(void);
/*
* Get a history entry by index (0 = most recent).
* Returns NULL if index is out of range.
* Get a history URL by index (0 = most recent).
* Returns a newly allocated string (caller must g_free), or NULL if
* index is out of range.
*/
const char *history_get(int index);
char *history_get(int index);
/*
* Load history from ~/.sovereign_browser/history.txt.
* Called once at startup.
* Get the title for a history entry by index.
* Returns a newly allocated string, or NULL if no title / out of range.
*/
void history_load(void);
char *history_get_title(int index);
/*
* Clear all history entries and delete the file.
* Clear all history entries.
*/
void history_clear(void);
+34 -192
View File
@@ -1,5 +1,14 @@
/*
* key_store.c — Nostr identity persistence for sovereign_browser
* key_store.c — Nostr identity (in-memory only) for sovereign_browser
*
* Private keys are NEVER persisted to disk. They live only in RAM for
* the duration of the session and are gone when the browser quits.
*
* This module provides:
* - key_store_create_signer() — create a nostr_signer_t from an
* in-memory identity struct
* - key_store_delete_legacy_file() — defensively delete any
* identity.json left by a previous version that persisted keys
*/
#include "key_store.h"
@@ -7,203 +16,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include <errno.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip006.h"
#include "nostr_core/nip019.h"
/* ── Path helpers ─────────────────────────────────────────────── */
int key_store_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
/* Ensure ~/.sovereign_browser/ exists. */
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/identity.json", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Save / load ──────────────────────────────────────────────── */
static const char *method_to_string(key_store_method_t m) {
switch (m) {
case KEY_STORE_METHOD_LOCAL: return "local";
case KEY_STORE_METHOD_SEED: return "seed";
case KEY_STORE_METHOD_READONLY: return "readonly";
case KEY_STORE_METHOD_NIP46: return "nip46";
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
default: return "none";
}
}
static key_store_method_t string_to_method(const char *s) {
if (!s) return KEY_STORE_METHOD_NONE;
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
return KEY_STORE_METHOD_NONE;
}
int key_store_save(const key_store_identity_t *identity) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return -1;
}
/* Write with restrictive permissions. */
fchmod(fileno(f), 0600);
fprintf(f, "{\n");
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
if (identity->method == KEY_STORE_METHOD_LOCAL ||
identity->method == KEY_STORE_METHOD_SEED) {
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
}
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
}
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
}
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
}
fprintf(f, "\n}\n");
fclose(f);
return 0;
}
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
/* Skip whitespace and colon. */
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
if (*p != '"') return -1;
p++;
size_t i = 0;
while (*p && *p != '"' && i < out_sz - 1) {
if (*p == '\\' && p[1]) {
p++;
}
out[i++] = *p++;
}
out[i] = '\0';
return (i > 0 || *p == '"') ? 0 : -1;
}
static int json_extract_int(const char *json, const char *key, int *out) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
char *end;
long val = strtol(p, &end, 10);
if (end == p) return -1;
*out = (int)val;
return 0;
}
int key_store_load(key_store_identity_t *out) {
memset(out, 0, sizeof(*out));
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
if (errno == ENOENT) return 1; /* no identity file */
return -1;
}
/* Read the whole file (small). */
char buf[4096];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
char method_str[32];
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
return -1;
}
out->method = string_to_method(method_str);
if (out->method == KEY_STORE_METHOD_NONE) {
return -1;
}
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
return -1;
}
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
return 0;
}
int key_store_clear(void) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
if (unlink(path) != 0 && errno != ENOENT) {
return -1;
}
return 0;
}
/* ── Signer creation ──────────────────────────────────────────── */
/* ── Signer creation ──────────────────────────────────────────────── */
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
if (identity == NULL) {
@@ -267,3 +87,25 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
return NULL;
}
}
/* ── Legacy file cleanup ──────────────────────────────────────────── */
int key_store_delete_legacy_file(void) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char path[512];
int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json",
home);
if (n < 0 || (size_t)n >= sizeof(path)) {
return -1;
}
/* Delete the file if it exists. ENOENT is not an error. */
if (unlink(path) != 0 && errno != ENOENT) {
return -1;
}
return 0;
}
+20 -33
View File
@@ -1,8 +1,13 @@
/*
* key_store.h — Nostr identity persistence for sovereign_browser
* key_store.h — Nostr identity (in-memory only) for sovereign_browser
*
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
* so the browser can restore the signer on startup without re-prompting.
* IMPORTANT: Private keys are NEVER persisted to disk. They live only in
* working memory (RAM) for the duration of the session and are gone when
* the browser quits. The user must sign in again on each launch.
*
* The key_store_identity_t struct is populated by the login dialog or CLI
* login, held in memory, and used to create a nostr_signer_t. It is not
* written to any file.
*/
#ifndef KEY_STORE_H
@@ -18,14 +23,14 @@ extern "C" {
/* Login methods supported by the browser. */
typedef enum {
KEY_STORE_METHOD_NONE = 0,
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
} key_store_method_t;
/* Identity record persisted to disk. */
/* Identity record (in-memory only — never persisted). */
typedef struct {
key_store_method_t method;
@@ -33,10 +38,10 @@ typedef struct {
char pubkey_hex[65];
/* Hex private key (local + seed methods only; 64 chars + NUL).
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
* Held in memory only — never written to disk. */
char privkey_hex[65];
/* BIP-39 mnemonic (seed method only). */
/* BIP-39 mnemonic (seed method only). In memory only. */
char mnemonic[256];
/* NIP-46 bunker URL (nip46 method only). */
@@ -49,32 +54,7 @@ typedef struct {
} key_store_identity_t;
/*
* Get the path to the identity file (~/.sovereign_browser/identity.json).
* Creates the directory if it doesn't exist.
* Returns 0 on success, -1 on error.
*/
int key_store_path(char *out, size_t out_sz);
/*
* Save an identity to disk.
* Returns 0 on success, -1 on error.
*/
int key_store_save(const key_store_identity_t *identity);
/*
* Load an identity from disk.
* Returns 0 on success, 1 if no identity file exists, -1 on error.
*/
int key_store_load(key_store_identity_t *out);
/*
* Delete the identity file (logout).
* Returns 0 on success, -1 on error.
*/
int key_store_clear(void);
/*
* Create a nostr_signer_t from a loaded identity.
* Create a nostr_signer_t from an in-memory identity.
* For readonly: returns NULL (caller should use pubkey_hex directly).
* For nsigner: creates the appropriate transport-backed signer.
* Caller must free the returned signer with nostr_signer_free().
@@ -82,6 +62,13 @@ int key_store_clear(void);
*/
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
/*
* Defensively delete any legacy identity.json file from a previous
* version that persisted keys to disk. Call once at startup.
* Returns 0 on success (including if the file doesn't exist), -1 on error.
*/
int key_store_delete_legacy_file(void);
#ifdef __cplusplus
}
#endif
+74 -12
View File
@@ -11,6 +11,7 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include <string.h>
#include <stdlib.h>
@@ -78,6 +79,7 @@ typedef struct {
GtkWidget *status_label; /* error/status display */
login_result_t *result; /* output */
gboolean done; /* dialog completed */
gboolean no_login; /* user chose "No Login" (browse without identity) */
} login_ctx_t;
/* Helper: get the method name of the currently selected tab. */
@@ -577,6 +579,18 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Unknown method.");
}
/* "No Login" — browse without a Nostr identity. The browser works
* normally; window.nostr just won't be available for sign requests. */
static void on_no_login_clicked(GtkWidget *btn, gpointer user_data) {
(void)btn;
login_ctx_t *ctx = (login_ctx_t *)user_data;
ctx->done = TRUE;
ctx->no_login = TRUE;
/* Return success with an empty result (method=NONE, no signer). */
memset(ctx->result, 0, sizeof(*ctx->result));
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
}
static void on_cancel_clicked(GtkWidget *btn, gpointer user_data) {
(void)btn;
login_ctx_t *ctx = (login_ctx_t *)user_data;
@@ -732,7 +746,7 @@ static void on_transport_changed(GtkComboBox *combo, gpointer user_data) {
gtk_widget_hide(enum_btn);
gtk_widget_hide(service_box);
break;
case 3: /* Qubes qrexec */
case 3: /* Other Qube (Qubes qrexec) */
gtk_label_set_text(GTK_LABEL(device_label), "Target Qube:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
/* Set default value so the user doesn't need to type it. */
@@ -954,21 +968,26 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Qubes qrexec");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 0);
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Other Qube");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 3);
gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0);
/* Device path entry — label and placeholder adapt to transport type. */
GtkWidget *device_label = gtk_label_new("Device Path:");
/* Device path entry — label and placeholder adapt to transport type.
* Default transport is "Other Qube" (qrexec), so initial UI reflects
* that. The on_transport_changed callback updates these when the user
* switches transport. */
GtkWidget *device_label = gtk_label_new("Target Qube:");
gtk_widget_set_halign(device_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0);
GtkWidget *device_entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40);
gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0);
/* Enumerate serial devices button (only shown for serial transport). */
/* Enumerate serial devices button (only shown for serial transport).
* Hidden by default since qrexec is the default transport. */
GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices");
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry);
@@ -976,6 +995,7 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_widget_set_sensitive(enum_btn, FALSE);
#endif
gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0);
gtk_widget_hide(enum_btn);
/* Service name field (only shown for qrexec transport). */
GtkWidget *service_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
@@ -989,8 +1009,8 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_entry_set_width_chars(GTK_ENTRY(service_entry), 30);
gtk_box_pack_start(GTK_BOX(service_box), service_entry, FALSE, FALSE, 0);
/* Initially hide the service field (serial is the default transport). */
gtk_widget_hide(service_box);
/* Show the service field by default (qrexec is the default transport). */
gtk_widget_show_all(service_box);
/* Connect transport change callback to update the UI dynamically. */
g_signal_connect(transport_combo, "changed",
@@ -1047,6 +1067,18 @@ static void on_detect_serial(GtkWidget *btn, gpointer user_data) {
/* ── Main dialog ──────────────────────────────────────────────── */
/* Timeout callback to check if the agent has logged in.
* If so, close the dialog automatically. */
static gboolean agent_login_check(gpointer data) {
GtkDialog *dialog = GTK_DIALOG(data);
if (agent_login_was_performed_by_agent()) {
g_print("[login] Agent login detected, closing dialog.\n");
gtk_dialog_response(dialog, GTK_RESPONSE_ACCEPT);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
int login_dialog_run(GtkWindow *parent, login_result_t *result) {
memset(result, 0, sizeof(*result));
@@ -1065,12 +1097,20 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
/* Add custom buttons to the action area. */
/* Add custom buttons to the action area.
* Layout (left to right): No Login | Cancel | Sign In
* "No Login" is on the far left so the primary action (Sign In)
* remains on the right (conventional GTK button ordering). */
GtkWidget *action_area = gtk_dialog_get_action_area(GTK_DIALOG(dialog));
GtkWidget *cancel_btn = gtk_button_new_with_label("_Cancel");
GtkWidget *login_btn = gtk_button_new_with_label("_Sign In");
GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");
GtkWidget *cancel_btn = gtk_button_new_with_label("Cancel");
GtkWidget *login_btn = gtk_button_new_with_label("Sign In");
gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(action_area), cancel_btn, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(action_area), login_btn, FALSE, FALSE, 0);
gtk_widget_set_tooltip_text(no_login_btn,
"Browse without a Nostr identity. The browser works normally, "
"but window.nostr won't be available for signing.");
/* Apply monospace font styling to the dialog. */
GtkCssProvider *css = gtk_css_provider_new();
@@ -1134,15 +1174,37 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
/* Wire the custom buttons — these don't auto-respond, so the dialog
* stays open unless we explicitly call gtk_dialog_response. */
g_signal_connect(no_login_btn, "clicked", G_CALLBACK(on_no_login_clicked), &ctx);
g_signal_connect(cancel_btn, "clicked", G_CALLBACK(on_cancel_clicked), &ctx);
g_signal_connect(login_btn, "clicked", G_CALLBACK(on_login_clicked), &ctx);
g_object_set_data(G_OBJECT(dialog), "login-btn", login_btn);
g_object_set_data(G_OBJECT(dialog), "cancel-btn", cancel_btn);
g_object_set_data(G_OBJECT(dialog), "no-login-btn", no_login_btn);
gtk_widget_show_all(dialog);
/* Add a timeout to check if the agent has logged in. If so,
* close the dialog automatically (every 200ms). */
guint agent_check_id = g_timeout_add(200, agent_login_check, dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
/* Remove the timeout if it's still active. */
g_source_remove(agent_check_id);
/* If the agent logged in while the dialog was showing, return 0
* (success) but with an empty result — main.c will use the agent's
* login state instead. */
if (agent_login_was_performed_by_agent()) {
if (result->signer) {
nostr_signer_free(result->signer);
result->signer = NULL;
}
memset(result, 0, sizeof(*result));
gtk_widget_destroy(dialog);
return 0;
}
if (!ctx.done || response != GTK_RESPONSE_ACCEPT) {
if (result->signer) {
nostr_signer_free(result->signer);
+537 -441
View File
File diff suppressed because it is too large Load Diff
+1359 -84
View File
File diff suppressed because it is too large Load Diff
+28 -12
View File
@@ -27,7 +27,6 @@ static const char *NOSTR_SHIM_JS =
"\n"
" function bridgeCall(method, bodyObj) {\n"
" var url = BRIDGE_BASE + method;\n"
" var opts = {};\n"
"\n"
" if (bodyObj !== undefined && bodyObj !== null) {\n"
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
@@ -36,20 +35,37 @@ static const char *NOSTR_SHIM_JS =
" url += '?body=' + encodeURIComponent(bodyJson);\n"
" }\n"
"\n"
" return fetch(url).then(function(resp) {\n"
" return resp.text();\n"
" }).then(function(text) {\n"
" var data;\n"
" /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR\n"
" * enforce CORS on custom schemes even when registered as secure, but\n"
" * synchronous XHR to a secure custom scheme works without CORS headers.\n"
" * The bridge is local (sovereign:// URI scheme handler in C), so the\n"
" * call is effectively instant. */\n"
" return new Promise(function(resolve, reject) {\n"
" try {\n"
" data = JSON.parse(text);\n"
" var xhr = new XMLHttpRequest();\n"
" xhr.open('GET', url, false); /* synchronous */\n"
" xhr.send();\n"
" var text = xhr.responseText;\n"
" if (!text) {\n"
" reject(new Error('Bridge returned empty response'));\n"
" return;\n"
" }\n"
" var data;\n"
" try {\n"
" data = JSON.parse(text);\n"
" } catch(e) {\n"
" reject(new Error('Bridge returned invalid JSON: ' + text));\n"
" return;\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" reject(new Error(msg));\n"
" return;\n"
" }\n"
" resolve(data);\n"
" } catch(e) {\n"
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
" reject(new Error('Bridge request failed: ' + e.message));\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" throw new Error(msg);\n"
" }\n"
" return data;\n"
" });\n"
" }\n"
"\n"
+122
View File
@@ -0,0 +1,122 @@
/*
* qr.c QR code rendering wrapper around libqrencode
*
* Encodes text via QRcode_encodeString() and renders the module matrix
* into an RGBA pixel buffer, then wraps it in a GdkPixbuf. The pixbuf
* can be saved to PNG in memory (qr_render_png) or used directly by GTK
* widgets (qr_render_pixbuf).
*
* The QRcode->data[] layout is row-major, one byte per module, with the
* least-significant bit indicating a dark module (1 = dark, 0 = light).
* See <qrencode.h> for details.
*/
#include "qr.h"
#include <qrencode.h>
#include <string.h>
#include <stdlib.h>
/* ── Internal: render QRcode modules into a GdkPixbuf ──────────────── */
static GdkPixbuf *qr_code_to_pixbuf(const QRcode *code, int box_size,
int border) {
if (code == NULL || code->data == NULL || code->width <= 0) {
return NULL;
}
if (box_size < 1) box_size = 1;
if (border < 0) border = 0;
int modules = code->width; /* width == height for QR codes */
int dim = (modules + 2 * border) * box_size;
/* Allocate an RGBA buffer (gdk-pixbuf uses 8 bits/channel, 4 channels
* when an alpha channel is present; we use RGB to keep it simple and
* smaller). */
GdkPixbuf *pb = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE /* no alpha */,
8, dim, dim);
if (pb == NULL) return NULL;
int rowstride = gdk_pixbuf_get_rowstride(pb);
int n_channels = gdk_pixbuf_get_n_channels(pb);
guchar *pixels = gdk_pixbuf_get_pixels(pb);
/* Fill with white (the quiet zone + light modules). */
for (int y = 0; y < dim; y++) {
guchar *row = pixels + y * rowstride;
for (int x = 0; x < dim; x++) {
row[x * n_channels + 0] = 255; /* R */
row[x * n_channels + 1] = 255; /* G */
row[x * n_channels + 2] = 255; /* B */
}
}
/* Paint dark modules. QRcode->data is row-major: data[y * width + x],
* and bit 0 (LSB) of each byte is the module color (1 = dark). */
for (int my = 0; my < modules; my++) {
for (int mx = 0; mx < modules; mx++) {
unsigned char b = code->data[my * code->width + mx];
if (b & 1) {
/* Dark module — paint the box_size×box_size block. */
int px0 = (mx + border) * box_size;
int py0 = (my + border) * box_size;
for (int dy = 0; dy < box_size; dy++) {
guchar *row = pixels + (py0 + dy) * rowstride;
for (int dx = 0; dx < box_size; dx++) {
int idx = (px0 + dx) * n_channels;
row[idx + 0] = 0; /* R */
row[idx + 1] = 0; /* G */
row[idx + 2] = 0; /* B */
}
}
}
}
}
return pb;
}
/* ── Public API ────────────────────────────────────────────────────── */
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border) {
if (text == NULL || text[0] == '\0') return NULL;
/* version=0 → auto-select the smallest version that fits.
* level=QR_ECLEVEL_M medium error correction (good default for
* identity/URI codes that might be scanned at an angle or off-screen).
* hint=QR_MODE_8 treat input as 8-bit data (handles URIs, npubs, etc.).
* casesensitive=1 don't fold to uppercase. */
QRcode *code = QRcode_encodeString(text, 0, QR_ECLEVEL_M,
QR_MODE_8, 1);
if (code == NULL) {
return NULL;
}
GdkPixbuf *pb = qr_code_to_pixbuf(code, box_size, border);
QRcode_free(code);
return pb;
}
GBytes *qr_render_png(const char *text, int box_size, int border) {
GdkPixbuf *pb = qr_render_pixbuf(text, box_size, border);
if (pb == NULL) return NULL;
gchar *buffer = NULL;
gsize size = 0;
GError *err = NULL;
if (!gdk_pixbuf_save_to_bufferv(pb, &buffer, &size, "png",
NULL, NULL, &err)) {
g_printerr("[qr] Failed to encode PNG: %s\n",
err ? err->message : "(unknown)");
if (err) g_error_free(err);
g_object_unref(pb);
return NULL;
}
g_object_unref(pb);
/* g_bytes_new_take takes ownership of buffer (g_free). */
GBytes *bytes = g_bytes_new_take(buffer, size);
return bytes;
}
+49
View File
@@ -0,0 +1,49 @@
/*
* qr.h QR code rendering wrapper around libqrencode
*
* Provides a stable API so the rest of the codebase doesn't include
* <qrencode.h> directly. Renders a QR code for a given text string
* into either an in-memory PNG (GBytes) or a GdkPixbuf (for GTK widgets).
*
* Dependencies: libqrencode (LGPL-2.1+), gdk-pixbuf (via GTK).
*/
#ifndef QR_H
#define QR_H
#include <glib.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Render text as a QR code and return the PNG image in memory.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module (e.g. 8 for a chunky, scannable code).
* border : number of quiet-zone modules around the code (4 is standard).
*
* Returns a newly allocated GBytes containing PNG data, or NULL on failure.
* Caller must unref with g_bytes_unref().
*/
GBytes *qr_render_png(const char *text, int box_size, int border);
/*
* Render text as a QR code and return a GdkPixbuf.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module.
* border : number of quiet-zone modules around the code.
*
* Returns a newly allocated GdkPixbuf, or NULL on failure.
* Caller must unref with g_object_unref().
*/
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border);
#ifdef __cplusplus
}
#endif
#endif /* QR_H */
+184
View File
@@ -0,0 +1,184 @@
/*
* relay_fetch.c post-login bootstrap relay fetch for sovereign_browser
*
* Uses synchronous_query_relays_with_progress() from nostr_core_lib to
* query the user's kind 0/3/10002 events from the bootstrap relays, then
* stores them in the SQLite database via db_store_event().
*
* This runs in a background thread (relay_fetch_thread) so the browser
* is usable immediately after login. SQLite is opened with
* SQLITE_OPEN_FULLMUTEX so concurrent access from the main thread is safe.
*/
#include "relay_fetch.h"
#include "db.h"
#include "settings.h"
#include "bookmarks.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include <string.h>
#include <stdlib.h>
#include <glib.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* Maximum number of relays to parse from the bootstrap_relays setting. */
#define MAX_RELAYS 32
/* 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,
const char **relay_urls,
int relay_count) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] No pubkey, skipping bootstrap fetch\n");
return -1;
}
if (relay_urls == NULL || relay_count <= 0) {
g_printerr("[relay] No bootstrap relays configured\n");
return -1;
}
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, 30003, 30078]} */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
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
* from all relays. No NIP-42 auth needed for public events. */
int result_count = 0;
cJSON **results = synchronous_query_relays_with_progress(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
NULL, NULL, /* no progress callback */
0, NULL /* no NIP-42 auth */
);
cJSON_Delete(filter);
if (results == NULL || result_count <= 0) {
g_print("[relay] No events found (relays may be unreachable or "
"the user has no kind 0/3/10002 events)\n");
if (results) free(results);
return 0;
}
/* Store each event in the database. Kind 30003 (bookmark sets) are
* 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;
long kind = 0;
cJSON *k = cJSON_GetObjectItemCaseSensitive(results[i], "kind");
if (cJSON_IsNumber(k)) kind = (long)k->valuedouble;
if (kind == 30003) {
/* bookmarks_store_and_load_event stores in SQLite + decrypts. */
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++;
} else {
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
result_count);
}
}
cJSON_Delete(results[i]);
}
free(results);
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) {
char *pubkey_hex = (char *)data;
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] Thread started with no pubkey\n");
return NULL;
}
/* Parse bootstrap relays from settings. */
const browser_settings_t *s = settings_get();
char *relay_buf = g_strdup(s->bootstrap_relays);
const char *relay_urls[MAX_RELAYS];
int relay_count = 0;
/* Tokenize the buffer in place. */
char *saveptr = NULL;
char *line = strtok_r(relay_buf, "\n\r", &saveptr);
while (line != NULL && relay_count < MAX_RELAYS) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
relay_urls[relay_count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
if (relay_count == 0) {
g_print("[relay] No valid bootstrap relay URLs in settings\n");
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;
}
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;
}
+45
View File
@@ -0,0 +1,45 @@
/*
* relay_fetch.h post-login bootstrap relay fetch for sovereign_browser
*
* After the user logs in, fetches their kind 0 (profile), kind 3 (contacts),
* and kind 10002 (relay list) events from the configured bootstrap relays
* and stores them in the SQLite database.
*/
#ifndef RELAY_FETCH_H
#define RELAY_FETCH_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays
* and store them in the SQLite database via db_store_event().
*
* pubkey_hex user's hex pubkey (64 chars, no 0x prefix)
* relay_urls array of relay URL strings (e.g. "wss://relay.damus.io")
* relay_count number of relays in the array
*
* Returns the number of events fetched and stored, or -1 on error.
*/
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count);
/*
* Background thread entry point for relay_fetch_bootstrap.
* Pass a pointer to a g_strdup'd pubkey_hex string (the thread frees it).
* Reads bootstrap relays from the global settings singleton.
*
* Usage: g_thread_new("relay-fetch", relay_fetch_thread, g_strdup(pubkey));
*/
gpointer relay_fetch_thread(gpointer data);
#ifdef __cplusplus
}
#endif
#endif /* RELAY_FETCH_H */
+367
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
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 */
+90
View File
@@ -0,0 +1,90 @@
/*
* session.c tab session save/restore for sovereign_browser
*
* Saves open tab URLs (and titles) to the SQLite database (session table)
* on window close, and restores them on startup if settings.restore_session
* is true.
*/
#include "session.h"
#include "settings.h"
#include "tab_manager.h"
#include "db.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Save ─────────────────────────────────────────────────────────── */
void session_save(void) {
int count = tab_manager_count();
/* Build arrays of URLs and titles. */
const char **urls = g_new0(const char *, count);
const char **titles = g_new0(const char *, count);
int valid = 0;
for (int i = 0; i < count; i++) {
const char *url = tab_manager_get_url(i);
if (url != NULL && url[0] != '\0') {
urls[valid] = url;
tab_info_t *tab = tab_manager_get(i);
titles[valid] = (tab && tab->title[0]) ? tab->title : "";
valid++;
}
}
db_session_save(urls, titles, valid);
g_free(urls);
g_free(titles);
g_print("[session] Saved %d tab(s)\n", valid);
}
/* ── Restore ──────────────────────────────────────────────────────── */
int session_restore(void) {
const browser_settings_t *s = settings_get();
if (!s->restore_session) {
return 0;
}
char **urls = NULL;
char **titles = NULL;
int count = 0;
int rc = db_session_load(&urls, &titles, &count);
if (rc < 0 || count == 0) {
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
if (titles) {
for (int i = 0; i < count; i++) g_free(titles[i]);
g_free(titles);
}
return 0;
}
int restored = 0;
for (int i = 0; i < count; i++) {
if (urls[i] && urls[i][0]) {
int index = tab_manager_new_tab(urls[i]);
if (index >= 0) {
restored++;
}
}
}
/* Free the arrays. */
for (int i = 0; i < count; i++) {
g_free(urls[i]);
g_free(titles[i]);
}
g_free(urls);
g_free(titles);
g_print("[session] Restored %d tab(s)\n", restored);
return restored;
}
+35
View File
@@ -0,0 +1,35 @@
/*
* session.h tab session save/restore for sovereign_browser
*
* Saves the list of open tab URLs to ~/.sovereign_browser/session.txt
* on window close, and restores them on startup if settings.restore_session
* is true.
*/
#ifndef SESSION_H
#define SESSION_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Save the current set of open tab URLs to disk.
* Called on window destroy (before gtk_main_quit).
*/
void session_save(void);
/*
* Restore the saved session by creating a tab for each URL in
* ~/.sovereign_browser/session.txt.
*
* Returns the number of tabs created, or 0 if no session was restored
* (file missing, empty, or restore_session setting is false).
*/
int session_restore(void);
#ifdef __cplusplus
}
#endif
#endif /* SESSION_H */
+218
View File
@@ -0,0 +1,218 @@
/*
* settings.c browser preferences for sovereign_browser
*
* Persists settings to the SQLite database (key_value table). Each setting
* is stored as a key-value pair. The database must be initialized (db_init())
* before settings_load() is called.
*/
#include "settings.h"
#include "db.h"
#include <gtk/gtk.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
/* ── Global singleton ─────────────────────────────────────────────── */
static browser_settings_t g_settings;
/* ── Defaults ─────────────────────────────────────────────────────── */
static void settings_set_defaults(browser_settings_t *s) {
s->restore_session = TRUE;
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
SETTINGS_NEW_TAB_URL_DEFAULT);
s->tab_bar_position = GTK_POS_TOP;
s->show_tab_close_buttons = TRUE;
s->middle_click_close = TRUE;
s->ctrl_tab_switch = TRUE;
s->max_tabs = SETTINGS_MAX_TABS_DEFAULT;
s->tab_drag_reorder = TRUE;
s->agent_server_enabled = TRUE;
s->agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins), "*");
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);
s->theme_dark = TRUE; /* default: dark mode */
s->inspector_x = -1; /* -1 = let window manager decide */
s->inspector_y = -1;
s->inspector_w = -1;
s->inspector_h = -1;
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
static gboolean parse_bool(const char *value, gboolean fallback) {
if (value == NULL) return fallback;
if (strcasecmp(value, "true") == 0 ||
strcasecmp(value, "yes") == 0 ||
strcasecmp(value, "1") == 0 ||
strcasecmp(value, "on") == 0) {
return TRUE;
}
if (strcasecmp(value, "false") == 0 ||
strcasecmp(value, "no") == 0 ||
strcasecmp(value, "0") == 0 ||
strcasecmp(value, "off") == 0) {
return FALSE;
}
return fallback;
}
static int parse_int(const char *value, int fallback) {
if (value == NULL || value[0] == '\0') return fallback;
char *end = NULL;
long v = strtol(value, &end, 10);
if (end == value) return fallback;
return (int)v;
}
static int parse_position(const char *value, int fallback) {
if (value == NULL) return fallback;
if (strcasecmp(value, "top") == 0) return GTK_POS_TOP;
if (strcasecmp(value, "bottom") == 0) return GTK_POS_BOTTOM;
if (strcasecmp(value, "left") == 0) return GTK_POS_LEFT;
if (strcasecmp(value, "right") == 0) return GTK_POS_RIGHT;
/* Allow numeric GTK_POS_* values too. */
return parse_int(value, fallback);
}
/* ── Position to string ───────────────────────────────────────────── */
static const char *position_to_string(int pos) {
switch (pos) {
case GTK_POS_TOP: return "top";
case GTK_POS_BOTTOM: return "bottom";
case GTK_POS_LEFT: return "left";
case GTK_POS_RIGHT: return "right";
default: return "top";
}
}
/* ── Load / Save (SQLite key_value table) ─────────────────────────── */
void settings_load(void) {
settings_set_defaults(&g_settings);
/* Read each setting from the key_value table. If a key is missing
* (NULL), the default is kept. */
const char *val;
val = db_kv_get("restore_session");
if (val) g_settings.restore_session = parse_bool(val, g_settings.restore_session);
val = db_kv_get("new_tab_url");
if (val) snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), "%s", val);
val = db_kv_get("tab_bar_position");
if (val) g_settings.tab_bar_position = parse_position(val, g_settings.tab_bar_position);
val = db_kv_get("show_tab_close_buttons");
if (val) g_settings.show_tab_close_buttons = parse_bool(val, g_settings.show_tab_close_buttons);
val = db_kv_get("middle_click_close");
if (val) g_settings.middle_click_close = parse_bool(val, g_settings.middle_click_close);
val = db_kv_get("ctrl_tab_switch");
if (val) g_settings.ctrl_tab_switch = parse_bool(val, g_settings.ctrl_tab_switch);
val = db_kv_get("max_tabs");
if (val) {
g_settings.max_tabs = parse_int(val, g_settings.max_tabs);
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
}
val = db_kv_get("tab_drag_reorder");
if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder);
val = db_kv_get("agent_server_enabled");
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
val = db_kv_get("agent_server_port");
if (val) {
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
}
val = db_kv_get("agent_allowed_origins");
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
val = db_kv_get("agent_login_timeout_ms");
if (val) {
g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms);
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
}
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);
val = db_kv_get("theme_dark");
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
val = db_kv_get("inspector_x");
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
val = db_kv_get("inspector_y");
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
val = db_kv_get("inspector_w");
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
val = db_kv_get("inspector_h");
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
}
void settings_save(void) {
char buf[32];
db_kv_set("restore_session", g_settings.restore_session ? "true" : "false");
db_kv_set("new_tab_url", g_settings.new_tab_url);
db_kv_set("tab_bar_position", position_to_string(g_settings.tab_bar_position));
db_kv_set("show_tab_close_buttons", g_settings.show_tab_close_buttons ? "true" : "false");
db_kv_set("middle_click_close", g_settings.middle_click_close ? "true" : "false");
db_kv_set("ctrl_tab_switch", g_settings.ctrl_tab_switch ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.max_tabs);
db_kv_set("max_tabs", buf);
db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false");
db_kv_set("agent_server_enabled", g_settings.agent_server_enabled ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
db_kv_set("agent_server_port", buf);
db_kv_set("agent_allowed_origins", g_settings.agent_allowed_origins);
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
db_kv_set("agent_login_timeout_ms", buf);
/* 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);
db_kv_set("theme_dark", g_settings.theme_dark ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
db_kv_set("inspector_x", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
db_kv_set("inspector_y", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
db_kv_set("inspector_w", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
db_kv_set("inspector_h", buf);
}
const browser_settings_t *settings_get(void) {
return &g_settings;
}
browser_settings_t *settings_get_mutable(void) {
return &g_settings;
}
+82
View File
@@ -0,0 +1,82 @@
/*
* settings.h browser preferences for sovereign_browser
*
* Persists user-configurable settings to ~/.sovereign_browser/settings.conf
* as simple key=value lines. A global singleton is loaded at startup and
* accessed via settings_get().
*/
#ifndef SETTINGS_H
#define SETTINGS_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com"
#define SETTINGS_NEW_TAB_URL_MAX 512
#define SETTINGS_MAX_TABS_DEFAULT 50
#define SETTINGS_AGENT_PORT_DEFAULT 17777
#define SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT 30000 /* ms */
#define SETTINGS_AGENT_ORIGINS_MAX 256
#define SETTINGS_BOOTSTRAP_RELAYS_MAX 2048
#define SETTINGS_BOOTSTRAP_RELAYS_DEFAULT \
"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 */
char new_tab_url[SETTINGS_NEW_TAB_URL_MAX];
int tab_bar_position; /* GTK_POS_TOP / BOTTOM / LEFT / RIGHT */
gboolean show_tab_close_buttons; /* show per-tab close button */
gboolean middle_click_close; /* middle-click on tab closes it */
gboolean ctrl_tab_switch; /* Ctrl+Tab cycles tabs */
int max_tabs; /* maximum simultaneous tabs */
gboolean tab_drag_reorder; /* allow drag to reorder tabs */
gboolean agent_server_enabled; /* enable agent WebSocket server */
int agent_server_port; /* port for agent server */
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 */
gboolean theme_dark; /* dark mode for sovereign:// pages */
int inspector_x; /* inspector detached window X (-1 = default) */
int inspector_y; /* inspector detached window Y (-1 = default) */
int inspector_w; /* inspector detached window width (-1 = default) */
int inspector_h; /* inspector detached window height (-1 = default) */
} browser_settings_t;
/*
* Load settings from disk into the global singleton.
* If the file doesn't exist or a key is missing, defaults are used.
* Call once at startup.
*/
void settings_load(void);
/*
* Save the current global settings to disk.
*/
void settings_save(void);
/*
* Get a pointer to the global settings singleton.
* Always valid after settings_load().
*/
const browser_settings_t *settings_get(void);
/*
* Get a mutable pointer to the global settings.
* Used by the settings dialog to modify values; call settings_save() after.
*/
browser_settings_t *settings_get_mutable(void);
#ifdef __cplusplus
}
#endif
#endif /* SETTINGS_H */
+346
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
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 */
+247
View File
@@ -0,0 +1,247 @@
/*
* 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>u"
},
[SHORTCUT_TOGGLE_FULLSCREEN] = {
"toggle_fullscreen", "Toggle fullscreen",
"Enter or exit fullscreen mode", "F11"
},
[SHORTCUT_TOGGLE_INSPECTOR] = {
"toggle_inspector", "Toggle inspector",
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
},
};
/* ── 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();
/* gtk_accelerator_parse() stores lowercase keyvals for letter keys,
* but GdkEventKey delivers the uppercase keyval when Shift is held.
* Normalize the event keyval to lowercase for comparison. */
guint event_keyval = gdk_keyval_to_lower(event->keyval);
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);
}
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
* 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_TOGGLE_INSPECTOR,
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 */
+2520
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
/*
* tab_manager.h multi-tab management for sovereign_browser
*
* Wraps a GtkNotebook and manages per-tab state: each tab has its own
* toolbar (hamburger menu + URL entry) and WebKitWebView. All webviews
* share a single WebKitWebContext so the sovereign:// bridge, security
* settings, and TLS policy apply to every tab.
*/
#ifndef TAB_MANAGER_H
#define TAB_MANAGER_H
#include <gtk/gtk.h>
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
#define TAB_TITLE_MAX 256
#define TAB_URL_MAX 2048
typedef struct {
WebKitWebView *webview;
GtkWidget *url_entry;
GtkWidget *hamburger;
GtkWidget *page; /* the vertical box (toolbar + webview) */
GtkWidget *tab_label; /* composite label widget for the tab strip */
GtkWidget *title_label; /* child of tab_label */
GtkWidget *favicon; /* favicon image in tab_label */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
} tab_info_t;
/*
* Initialize the tab manager. Creates a GtkNotebook, adds it to parent,
* and stores the shared web context for creating new webviews.
*
* parent: the GtkContainer (typically a GtkBox) to add the notebook to
* ctx: the shared WebKitWebContext for all tabs
* window: the top-level GtkWindow (for menu dialogs, file choosers, etc.)
*/
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window);
/*
* Create a new tab and load the given URL (or the default new-tab URL
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
* tabs reached).
*/
int tab_manager_new_tab(const char *url);
/*
* Close the tab at the given index. If it's the last tab, the window
* destroy handler will fire (caller is responsible for quitting).
*/
void tab_manager_close_tab(int index);
/*
* Close the currently active tab.
*/
void tab_manager_close_active(void);
/*
* Get the index of the currently active tab.
*/
int tab_manager_get_active_index(void);
/*
* Switch to the tab at the given index.
*/
void tab_manager_switch_to(int index);
/*
* Get the tab_info_t for the active tab, or NULL if no tabs exist.
*/
tab_info_t *tab_manager_get_active(void);
/*
* Get the tab_info_t for the tab at the given index, or NULL.
*/
tab_info_t *tab_manager_get(int index);
/*
* Get the number of open tabs.
*/
int tab_manager_count(void);
/*
* Update the title of the tab at the given index. Called from the
* load-changed signal handler when the page title becomes available.
*/
void tab_manager_set_title(int index, const char *title);
/*
* Get the URL of the tab at the given index (for session save).
* Returns NULL if index is out of range.
*/
const char *tab_manager_get_url(int index);
/*
* Cycle to the next tab (wraps around). Used by Ctrl+Tab / Ctrl+PageDown.
*/
void tab_manager_next(void);
/*
* Cycle to the previous tab (wraps around). Used by Ctrl+Shift+Tab /
* Ctrl+PageUp.
*/
void tab_manager_prev(void);
/*
* Close all tabs except the one at the given index.
*/
void tab_manager_close_others(int index);
/*
* Close all tabs to the right of the given index.
*/
void tab_manager_close_to_right(int index);
/*
* Close all open tabs. Closes from the highest index down to 0 so that
* indices remain valid during iteration.
*/
void tab_manager_close_all(void);
/*
* Duplicate the tab at the given index (open a new tab with the same URL).
*/
void tab_manager_duplicate(int index);
/*
* Reload the tab at the given index.
*/
void tab_manager_reload(int index);
/*
* Apply current settings (tab bar position, drag reorder, close buttons).
* Call after settings_change() or at init.
*/
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);
/*
* Toggle the WebKit Web Inspector for the active tab.
* If the inspector is visible, it is closed; otherwise it is shown.
* The inspector's detached window position and size are saved to
* settings and restored on the next show.
*/
void tab_manager_toggle_inspector(void);
#ifdef __cplusplus
}
#endif
#endif /* TAB_MANAGER_H */
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.3"
#define SB_VERSION "v0.0.19"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 3
#define SB_VERSION_PATCH 19
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
Test all agent login functionality for sovereign_browser.
Connects to the WebSocket agent server and tests each login method.
"""
import websocket
import json
import os
import subprocess
import sys
import time
SERVER_URL = "ws://localhost:17777/agent"
HTTP_URL = "http://localhost:17777/"
def send_tool(ws, tool_name, params=None, msg_id=1):
"""Send a tool command and return the response."""
msg = {"id": msg_id, "tool": tool_name, "params": params or {}}
ws.send(json.dumps(msg))
result = ws.recv()
return json.loads(result)
def test_status():
"""Test HTTP status endpoint."""
import urllib.request
try:
resp = urllib.request.urlopen(HTTP_URL, timeout=5)
data = json.loads(resp.read())
print(f"[PASS] HTTP status: {data}")
return True
except Exception as e:
print(f"[FAIL] HTTP status: {e}")
return False
def test_login_status_not_logged_in():
"""Test login_status before login."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login_status", {}, 1)
ws.close()
if resp.get("success") and not resp["data"]["logged_in"]:
print(f"[PASS] login_status (not logged in): {resp}")
return True
else:
print(f"[FAIL] login_status (not logged in): {resp}")
return False
def test_login_local():
"""Test login with a random local key."""
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
print(f" Generated privkey: {privkey}")
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "local", "privkey_hex": privkey}, 2)
ws.close()
if resp.get("success") and resp["data"]["pubkey"]:
print(f"[PASS] login local: pubkey={resp['data']['pubkey']}, npub={resp['data']['npub']}")
return True, privkey
else:
print(f"[FAIL] login local: {resp}")
return False, privkey
def test_login_already_logged_in():
"""Test that login fails when already logged in."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "readonly", "npub": "npub1invalid"}, 3)
ws.close()
if not resp.get("success") and "ALREADY_LOGGED_IN" in resp.get("error", {}).get("code", ""):
print(f"[PASS] login already logged in rejected: {resp}")
return True
else:
print(f"[FAIL] login already logged in: {resp}")
return False
def test_login_status_logged_in():
"""Test login_status after login."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login_status", {}, 4)
ws.close()
if resp.get("success") and resp["data"]["logged_in"]:
print(f"[PASS] login_status (logged in): method={resp['data']['method']}, pubkey={resp['data']['pubkey']}")
return True
else:
print(f"[FAIL] login_status (logged in): {resp}")
return False
def test_logout():
"""Test logout."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "logout", {}, 5)
ws.close()
if resp.get("success"):
print(f"[PASS] logout: {resp}")
return True
else:
print(f"[FAIL] logout: {resp}")
return False
def test_login_readonly():
"""Test login with readonly npub."""
# First generate a key and get the npub
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
# Login with local to get the npub, then logout and test readonly
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "local", "privkey_hex": privkey}, 6)
if not resp.get("success"):
print(f"[FAIL] login readonly (setup): {resp}")
ws.close()
return False
npub = resp["data"]["npub"]
pubkey = resp["data"]["pubkey"]
print(f" Generated npub: {npub}")
# Logout
resp2 = send_tool(ws, "logout", {}, 7)
if not resp2.get("success"):
print(f"[FAIL] login readonly (logout): {resp2}")
ws.close()
return False
# Login readonly with the npub
resp3 = send_tool(ws, "login", {"method": "readonly", "npub": npub}, 8)
ws.close()
if resp3.get("success") and resp3["data"]["readonly"]:
print(f"[PASS] login readonly: pubkey={resp3['data']['pubkey']}, readonly={resp3['data']['readonly']}")
return True
else:
print(f"[FAIL] login readonly: {resp3}")
return False
def test_switch_identity():
"""Test switch_identity."""
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "switch_identity", {"method": "local", "privkey_hex": privkey}, 9)
ws.close()
if resp.get("success"):
print(f"[PASS] switch_identity: pubkey={resp['data']['pubkey']}")
return True
else:
print(f"[FAIL] switch_identity: {resp}")
return False
def test_browser_tools_after_login():
"""Test that browser tools work after login."""
ws = websocket.create_connection(SERVER_URL, timeout=15)
# Open a page
resp = send_tool(ws, "open", {"url": "https://example.com"}, 10)
print(f" open: {resp.get('success')}")
# Wait a moment for the page to load
time.sleep(2)
# Get URL
resp = send_tool(ws, "get_url", {}, 11)
print(f" get_url: {resp}")
# Get title
resp = send_tool(ws, "get_title", {}, 12)
print(f" get_title: {resp}")
# Snapshot
resp = send_tool(ws, "snapshot", {"interactive": True, "compact": True}, 13)
if resp.get("success") and resp["data"].get("snapshot"):
snapshot = resp["data"]["snapshot"]
print(f"[PASS] snapshot: {len(snapshot)} chars, {resp['data'].get('refCount', 0)} refs")
# Print first few lines
for line in snapshot.split("\n")[:5]:
print(f" {line}")
else:
print(f"[FAIL] snapshot: {resp}")
# Tab list
resp = send_tool(ws, "tab_list", {}, 14)
if resp.get("success"):
tabs = resp["data"]["tabs"]
print(f"[PASS] tab_list: {len(tabs)} tab(s)")
else:
print(f"[FAIL] tab_list: {resp}")
ws.close()
return True
def main():
print("=" * 60)
print("sovereign_browser agent login test suite")
print("=" * 60)
results = []
# Test 1: HTTP status
print("\n--- Test 1: HTTP status endpoint ---")
results.append(test_status())
# Test 2: login_status (not logged in)
print("\n--- Test 2: login_status (not logged in) ---")
results.append(test_login_status_not_logged_in())
# Test 3: login with local key
print("\n--- Test 3: login with local key ---")
ok, _ = test_login_local()
results.append(ok)
# Test 4: login when already logged in (should fail)
print("\n--- Test 4: login when already logged in ---")
results.append(test_login_already_logged_in())
# Test 5: login_status (logged in)
print("\n--- Test 5: login_status (logged in) ---")
results.append(test_login_status_logged_in())
# Test 6: logout
print("\n--- Test 6: logout ---")
results.append(test_logout())
# Test 7: login readonly
print("\n--- Test 7: login readonly ---")
results.append(test_login_readonly())
# Test 8: switch_identity
print("\n--- Test 8: switch_identity ---")
results.append(test_switch_identity())
# Test 9: browser tools after login
print("\n--- Test 9: browser tools after login ---")
results.append(test_browser_tools_after_login())
# Summary
print("\n" + "=" * 60)
passed = sum(results)
total = len(results)
print(f"Results: {passed}/{total} passed")
if passed == total:
print("ALL TESTS PASSED")
sys.exit(0)
else:
print("SOME TESTS FAILED")
sys.exit(1)
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
#
# test_cli_flags.sh — smoke test for sovereign_browser CLI flags
#
# Tests --version, --help, and flag validation without launching the GUI
# (these flags exit before gtk_init). Run from the project root:
#
# ./tests/test_cli_flags.sh
#
# Exit code: 0 = all passed, 1 = any failed.
set -euo pipefail
BIN="./sovereign_browser"
PASS=0
FAIL=0
ok() {
echo " PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo " FAIL: $1"
FAIL=$((FAIL + 1))
}
echo "=== CLI Flags Smoke Test ==="
# --version should print version and exit 0
echo "[1] --version"
out=$("$BIN" --version 2>&1) || true
if echo "$out" | grep -q "^sovereign_browser v"; then
ok "--version prints version string"
else
fail "--version output unexpected: $out"
fi
# -V short form
echo "[2] -V"
out=$("$BIN" -V 2>&1) || true
if echo "$out" | grep -q "^sovereign_browser v"; then
ok "-V works as short form"
else
fail "-V output unexpected: $out"
fi
# --help should print usage and exit 0
echo "[3] --help"
out=$("$BIN" --help 2>&1) || true
if echo "$out" | grep -q "Usage: sovereign_browser"; then
ok "--help prints usage"
else
fail "--help output unexpected"
fi
# -h short form
echo "[4] -h"
out=$("$BIN" -h 2>&1) || true
if echo "$out" | grep -q "Usage: sovereign_browser"; then
ok "-h works as short form"
else
fail "-h output unexpected"
fi
# Unknown flag should exit non-zero
echo "[5] unknown flag"
if "$BIN" --nonexistent-flag 2>/dev/null; then
fail "unknown flag should exit non-zero"
else
ok "unknown flag exits non-zero"
fi
# --login-method with invalid method should exit non-zero
echo "[6] invalid login method"
if "$BIN" --login-method bogus 2>/dev/null; then
fail "invalid login method should exit non-zero"
else
ok "invalid login method exits non-zero"
fi
# --login-method local without --nsec/--privkey should exit non-zero
echo "[7] local method without credentials"
if "$BIN" --login-method local 2>/dev/null; then
fail "local method without credentials should exit non-zero"
else
ok "local method without credentials exits non-zero"
fi
# --port out of range should exit non-zero
echo "[8] invalid port"
if "$BIN" --port 99999 2>/dev/null; then
fail "invalid port should exit non-zero"
else
ok "invalid port exits non-zero"
fi
# --max-tabs 0 should exit non-zero
echo "[9] invalid max-tabs"
if "$BIN" --max-tabs 0 2>/dev/null; then
fail "max-tabs 0 should exit non-zero"
else
ok "max-tabs 0 exits non-zero"
fi
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
exit $((FAIL > 0 ? 1 : 0))
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP Button Test Page</title>
<style>
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
button { display: block; margin: 10px 0; padding: 10px 20px; font-size: 16px; cursor: pointer; }
#result { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; }
.hidden { display: none; }
</style>
</head>
<body>
<h1>Button Test Page</h1>
<button id="btn-simple" onclick="document.getElementById('result').textContent='Simple button clicked!'">Click Me</button>
<button id="btn-addclass" class="min-h-[60vh] inline-flex items-center" onclick="document.getElementById('result').textContent='Tailwind button clicked!'">Tailwind Button</button>
<button id="btn-react-like" data-testid="react-btn">React-like Button</button>
<div id="result">No button clicked yet.</div>
<div id="hidden-section" class="hidden">
<p>This section appears after clicking the toggle button.</p>
<button id="btn-in-hidden" onclick="alert('Hidden button clicked!')">Hidden Button</button>
</div>
<button id="btn-toggle" onclick="var s=document.getElementById('hidden-section'); s.classList.toggle('hidden'); document.getElementById('result').textContent='Toggled hidden section'">Toggle Hidden Section</button>
<script>
// React-like event handling (addEventListener, not onclick)
document.getElementById('btn-react-like').addEventListener('click', function() {
document.getElementById('result').textContent = 'React-like button clicked via addEventListener!';
});
// Pointer event handling (like Radix UI)
var btnPointer = document.createElement('button');
btnPointer.id = 'btn-pointer';
btnPointer.textContent = 'Pointer Event Button';
btnPointer.addEventListener('pointerdown', function(e) {
document.getElementById('result').textContent = 'Pointer down detected!';
});
btnPointer.addEventListener('click', function(e) {
document.getElementById('result').textContent = 'Pointer button clicked!';
});
document.body.insertBefore(btnPointer, document.getElementById('result'));
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP Form Test Page</title>
<style>
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
label { display: block; margin: 10px 0 4px; font-weight: bold; }
input, textarea, select { display: block; width: 100%; max-width: 300px; padding: 8px; font-size: 14px; margin-bottom: 10px; }
button { padding: 10px 20px; font-size: 16px; cursor: pointer; margin: 5px 0; }
#output { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Form Test Page</h1>
<form id="test-form" onsubmit="event.preventDefault(); submitForm()">
<label for="name">Name:</label>
<input type="text" id="name" name="name" placeholder="Enter your name">
<label for="email">Email:</label>
<input type="email" id="email" name="email" placeholder="Enter your email">
<label for="message">Message:</label>
<textarea id="message" name="message" rows="3" placeholder="Enter a message"></textarea>
<label for="color">Favorite Color:</label>
<select id="color" name="color">
<option value="">Choose...</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<label for="agree">
<input type="checkbox" id="agree" name="agree"> I agree to the terms
</label>
<button type="submit">Submit Form</button>
<button type="button" id="clear-btn">Clear</button>
</form>
<div id="output">Form not submitted yet.</div>
<script>
function submitForm() {
var data = {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
message: document.getElementById('message').value,
color: document.getElementById('color').value,
agree: document.getElementById('agree').checked
};
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
}
document.getElementById('clear-btn').addEventListener('click', function() {
document.getElementById('test-form').reset();
document.getElementById('output').textContent = 'Form cleared.';
});
// React-like controlled input simulation
var reactInput = document.createElement('input');
reactInput.type = 'text';
reactInput.id = 'react-input';
reactInput.placeholder = 'React-like controlled input';
reactInput.setAttribute('data-testid', 'react-input');
var reactLabel = document.createElement('label');
reactLabel.textContent = 'React-like Input:';
reactLabel.htmlFor = 'react-input';
var form = document.getElementById('test-form');
form.insertBefore(reactLabel, form.firstChild);
form.insertBefore(reactInput, reactLabel.nextSibling);
// Track value via event listener (like React does)
var reactValue = '';
reactInput.addEventListener('input', function(e) {
reactValue = e.target.value;
});
var reactBtn = document.createElement('button');
reactBtn.type = 'button';
reactBtn.textContent = 'Show React Value';
reactBtn.addEventListener('click', function() {
document.getElementById('output').textContent = 'React input value: ' + reactValue;
});
form.appendChild(reactBtn);
</script>
</body>
</html>
Executable
+5
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