Compare commits

...
10 Commits
Author SHA1 Message Date
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
44 changed files with 15124 additions and 500 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).
+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
$(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.13
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.
+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.
+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]
```
+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 |
+483
View File
@@ -0,0 +1,483 @@
/*
* 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);
key_store_clear();
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 */
+897
View File
@@ -0,0 +1,897 @@
/*
* 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.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"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. */
cJSON *err_resp = rpc_error(rpc_id, -32000, "Session not found or expired");
char *err_str = cJSON_PrintUnformatted(err_resp);
cJSON_Delete(err_resp);
char *sse = build_sse_response(err_str);
free(err_str);
soup_server_message_set_status(msg, 404, NULL);
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
cJSON_Delete(request);
return;
}
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 */
+442
View File
@@ -0,0 +1,442 @@
/*
* 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"
" 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"
" };\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 */
+5302
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 */
+481
View File
@@ -0,0 +1,481 @@
/*
* 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"
" --no-save-identity Do not persist identity to disk\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_SAVE_IDENTITY,
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-save-identity", no_argument, 0, OPT_NO_SAVE_IDENTITY},
{"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_SAVE_IDENTITY:
out->no_save_identity = TRUE;
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);
/* TODO: persist identity via key_store_save() when that path is
* wired up. For now --no-save-identity is accepted but the save
* itself is a no-op (key_store_save is not called anywhere yet). */
(void)args->no_save_identity;
return 0;
}
+123
View File
@@ -0,0 +1,123 @@
/*
* 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) */
gboolean no_save_identity; /* --no-save-identity */
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 */
+404
View File
@@ -0,0 +1,404 @@
/*
* 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 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);
}
+105
View File
@@ -0,0 +1,105 @@
/*
* 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);
#ifdef __cplusplus
}
#endif
#endif /* DB_H */
+7 -2
View File
@@ -98,8 +98,13 @@ void history_add(const char *url) {
return;
}
/* Skip sovereign:// internal pages. */
if (strncmp(url, "sovereign://", 12) == 0) {
/* Skip sovereign:// API endpoints (not pages the user would want
* in their recents). The actual pages — sovereign://settings,
* sovereign://profile, 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://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;
}
+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);
+419 -426
View File
@@ -1,15 +1,26 @@
/*
* sovereign_browser — WebKitGTK + C99
*
* Minimal browser: a GTK window with a URL entry, a hamburger menu, and a
* WebKitWebView. Type a URL, press Enter, the page loads. The hamburger
* menu to the left of the URL bar exposes roadmap actions (reload, security
* strip toggle, FIPS, Nostr signing) as placeholders to wire up next.
* Multi-tab browser: a GTK window with a GtkNotebook tab strip. Each tab
* has its own toolbar (hamburger menu + URL entry) and WebKitWebView. All
* webviews share a single WebKitWebContext so the sovereign:// Nostr bridge,
* security settings, and TLS policy apply to every tab.
*
* On startup, a Nostr login dialog is shown. The user signs in with a local
* key (nsec), seed phrase, read-only npub, NIP-46 remote signer, or n_signer
* hardware. The resulting nostr_signer_t is held globally and will be used
* to inject window.nostr into every page (Phase 2).
* hardware. The resulting nostr_signer_t is held globally and backs the
* window.nostr injection in every tab.
*
* Features:
* - Tab creation (Ctrl+T, + button), closing (Ctrl+W, close button,
* middle-click)
* - Tab switching (Ctrl+Tab, Ctrl+Shift+Tab, Ctrl+PageUp/Down)
* - Right-click tab context menu (New, Close, Close Others, Close to
* Right, Duplicate, Reload)
* - Tab drag reordering
* - Session save/restore (configurable in Settings)
* - Settings page (sovereign://settings internal webpage: tabs, agent
* server, security — persisted to disk)
*
* Build: make. Run: ./sovereign_browser [url]
*/
@@ -27,11 +38,20 @@
#include "nostr_bridge.h"
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
#include "tab_manager.h"
#include "session.h"
#include "agent_server.h"
#include "agent_login.h"
#include "cli.h"
#include "db.h"
#include "relay_fetch.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
* The signer is created at login and held for the lifetime of the session.
* Phase 2 will use it to back the window.nostr injection.
* It backs the window.nostr injection in every tab via the sovereign://
* URI scheme bridge.
*/
typedef struct {
@@ -42,57 +62,52 @@ typedef struct {
} app_state_t;
static app_state_t g_state = {0};
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
/* ---- Menu action callbacks ------------------------------------------- */
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
static void on_menu_reload(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_reload_bypass_cache(webview);
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
g_state.readonly = readonly;
if (pubkey_hex) {
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
g_logged_in = TRUE;
}
static void on_menu_stop(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_stop_loading(webview);
}
}
static void on_menu_security_strip(GtkMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(GTK_CHECK_MENU_ITEM(item));
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
static void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
}
static void on_menu_nostr_sign(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
void app_clear_signer(void) {
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
}
/* Show the login dialog again (switch identity / re-auth). */
static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
const char *app_get_pubkey_hex(void) { return g_state.pubkey_hex; }
key_store_method_t app_get_method(void) { return g_state.method; }
gboolean app_get_readonly(void) { return g_state.readonly; }
/* ---- Menu proxy functions ------------------------------------------- *
* These wrap the app_state_t-aware callbacks so they match GTK signal
* handler signatures and can be called from tab_manager.c's hamburger
* menu builder.
*/
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Free the old signer. */
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
@@ -102,7 +117,6 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
/* Update the bridge so window.nostr uses the new signer. */
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
g_state.readonly);
@@ -111,7 +125,19 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
}
}
static void on_menu_logout(GtkMenuItem *item, gpointer data) {
void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.readonly = TRUE;
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
@@ -125,12 +151,36 @@ static void on_menu_logout(GtkMenuItem *item, gpointer data) {
g_print("[identity] logged out\n");
}
static void on_menu_about(GtkMenuItem *item, gpointer data) {
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(item);
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
void app_menu_fips_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
}
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
}
}
void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWidget *window = GTK_WIDGET(data);
if (window == NULL) {
return;
}
if (window == NULL) return;
GtkWidget *dialog = gtk_message_dialog_new(
GTK_WINDOW(window),
GTK_DIALOG_DESTROY_WITH_PARENT,
@@ -143,466 +193,409 @@ static void on_menu_about(GtkMenuItem *item, gpointer data) {
gtk_widget_show_all(dialog);
}
/* Toggle the WebKit Web Inspector. */
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
WebKitWebView *webview = WEBKIT_WEB_VIEW(data);
if (webview == NULL) return;
/* ---- Settings page (internal webpage) ------------------------------- *
* The hamburger-menu "Settings…" item navigates the active tab to the
* sovereign://settings internal page, which renders all preferences
* (tabs, agent server, security) and persists changes via the
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
WebKitWebInspector *inspector = webkit_web_view_get_inspector(webview);
if (inspector == NULL) return;
/* Just show the inspector — WebKitGTK handles the toggle. */
webkit_web_inspector_show(inspector);
}
/* History item callback: load the URL from the menu item label. */
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
WebKitWebView *webview = WEBKIT_WEB_VIEW(data);
if (webview == NULL || item == NULL) return;
const char *url = gtk_menu_item_get_label(item);
if (url && url[0]) {
webkit_web_view_load_uri(webview, url);
}
}
/* Clear history callback. */
static void on_menu_history_clear(GtkMenuItem *item, gpointer data) {
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
history_clear();
g_print("[history] cleared\n");
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
} else {
/* No active tab — open a new one pointed at the settings page. */
tab_manager_new_tab("sovereign://settings");
}
}
/* Rebuild the history submenu when it's shown (so it's always current). */
static void on_history_menu_show(GtkWidget *menu, gpointer user_data) {
WebKitWebView *webview = WEBKIT_WEB_VIEW(user_data);
/* ---- Keyboard shortcuts --------------------------------------------- */
/* Remove all existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
(void)widget;
(void)data;
const browser_settings_t *s = settings_get();
guint mods = event->state & gtk_accelerator_get_default_mod_mask();
/* Ctrl+T — new tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_t) {
tab_manager_new_tab(NULL);
return TRUE;
}
g_list_free(children);
/* Rebuild with current history. */
int hcount = history_count();
if (hcount == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no recent pages)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
int show = hcount < 20 ? hcount : 20;
for (int i = 0; i < show; i++) {
const char *url = history_get(i);
if (url == NULL) break;
char label[80];
if (strlen(url) > 75) {
snprintf(label, sizeof(label), "%.72s...", url);
/* Ctrl+W — close active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_w) {
tab_manager_close_active();
return TRUE;
}
/* Ctrl+L — focus URL bar of active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_l) {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->url_entry) {
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
}
return TRUE;
}
/* Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs. */
if (s->ctrl_tab_switch && (mods & GDK_CONTROL_MASK)) {
if (event->keyval == GDK_KEY_Tab) {
if (mods & GDK_SHIFT_MASK) {
tab_manager_prev();
} else {
snprintf(label, sizeof(label), "%s", url);
tab_manager_next();
}
GtkWidget *hitem = gtk_menu_item_new_with_label(label);
gtk_widget_set_tooltip_text(hitem, url);
g_signal_connect(hitem, "activate",
G_CALLBACK(on_menu_history_item), webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem);
return TRUE;
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *clear_item = gtk_menu_item_new_with_label("Clear Recents");
g_signal_connect(clear_item, "activate",
G_CALLBACK(on_menu_history_clear), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), clear_item);
}
gtk_widget_show_all(menu);
}
/* Open a local file via file chooser dialog. */
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File",
window,
GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT,
NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
/* Get the web view from the window's data. */
WebKitWebView *webview = g_object_get_data(G_OBJECT(window), "webview");
if (webview) {
webkit_web_view_load_uri(webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
/* Lock session: clear the signer but keep the saved identity.
* The user will need to re-authenticate (switch identity) to sign again. */
static void on_menu_lock_session(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.readonly = TRUE; /* no signing until re-auth */
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
/* Build the hamburger menu. */
static GtkWidget *build_hamburger_menu(GtkWidget *window,
WebKitWebView *webview) {
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file),
window);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload),
webview);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Recents submenu — rebuilt dynamically each time it's shown. */
GtkWidget *history_menu = gtk_menu_new();
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show),
webview);
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — show current identity status. */
char identity_label[80];
const char *method_name = "none";
switch (g_state.method) {
case KEY_STORE_METHOD_LOCAL: method_name = "local"; break;
case KEY_STORE_METHOD_SEED: method_name = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break;
default: break;
}
if (g_state.pubkey_hex[0]) {
/* Show first 8 chars of pubkey for identification. */
char short_pubkey[12];
memcpy(short_pubkey, g_state.pubkey_hex, 8);
short_pubkey[8] = '\0';
snprintf(identity_label, sizeof(identity_label),
"Identity: %s… (%s)", short_pubkey, method_name);
} else {
snprintf(identity_label, sizeof(identity_label), "Identity: none");
}
GtkWidget *item_identity = gtk_menu_item_new_with_label(identity_label);
gtk_widget_set_sensitive(item_identity, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_identity);
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate", G_CALLBACK(on_menu_switch_identity),
window);
g_signal_connect(item_lock, "activate", G_CALLBACK(on_menu_lock_session), NULL);
g_signal_connect(item_logout, "activate", G_CALLBACK(on_menu_logout), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(on_menu_security_strip), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate", G_CALLBACK(on_menu_fips), NULL);
g_signal_connect(item_nostr, "activate", G_CALLBACK(on_menu_nostr_sign),
NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate", G_CALLBACK(on_menu_inspector),
webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate", G_CALLBACK(on_menu_about),
window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
return button;
}
/* Normalize a bare string into a loadable URL. */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
static void on_url_activate(GtkEntry *entry, WebKitWebView *webview) {
const char *text = gtk_entry_get_text(entry);
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(webview, url);
g_free(url);
}
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
GtkEntry *url_entry) {
if (load_event == WEBKIT_LOAD_COMMITTED) {
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
gtk_entry_set_text(url_entry, uri);
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
g_print("[loaded] %s -- title: %s\n",
uri ? uri : "(null)",
(title && title[0]) ? title : "(none)");
/* Add to history on successful page load. */
if (uri != NULL && uri[0] != '\0') {
history_add(uri);
if (event->keyval == GDK_KEY_ISO_Left_Tab) {
tab_manager_prev();
return TRUE;
}
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
(void)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
/* Ctrl+PageDown — next tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Down) {
tab_manager_next();
return TRUE;
}
/* Ctrl+PageUp — previous tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Up) {
tab_manager_prev();
return TRUE;
}
return FALSE;
}
/* ---- Agent login callback ------------------------------------------ *
* Called by agent_server when an agent successfully logs in via the
* 'login' tool. The agent can log in at any time — while the GTK login
* dialog is showing, or after the browser is already running. We just
* set the flag; the do_login function checks it after the dialog
* returns and skips the dialog's result if the agent already logged in.
*/
static void agent_login_callback(void) {
g_logged_in = TRUE;
g_print("[login] Agent login detected.\n");
}
/* ---- Window destroy ------------------------------------------------- */
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
(void)data;
/* Save the session before cleanup. */
session_save();
/* Stop the agent server. */
agent_server_stop();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
nostr_cleanup();
db_close();
gtk_main_quit();
}
/* ---- Login flow ------------------------------------------------------ *
* Try to restore a saved identity. If none, show the login dialog.
* Returns 0 on success (signer or readonly loaded), -1 if user cancelled.
/* ---- Login flow (GTK dialog) ─────────────────────────────────────── *
* Shows the GTK login dialog. The agent server is already running at
* this point, so an agent can call 'login' while the dialog is showing
* (gtk_dialog_run runs a nested main loop that processes WebSocket
* events). After the dialog returns, we check if the agent already
* logged in — if so, we discard the dialog's result.
*/
static int do_login(GtkWindow *parent) {
/* Initialize nostr_core_lib. */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
/* nostr_init() is already called before this function. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
/* Dialog was cancelled. But if the agent logged in while the
* dialog was showing, proceed with the agent's login. */
if (g_logged_in) {
return 0;
}
return -1;
}
/* Show the login dialog every time — no plaintext key persistence.
* Encrypted key storage will be added in a future phase. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
return -1; /* cancelled */
/* If the agent already logged in while the dialog was showing,
* discard the dialog's result and use the agent's login. */
if (g_logged_in) {
g_print("[login] Agent login took priority over dialog.\n");
if (result.signer) {
nostr_signer_free(result.signer);
}
return 0;
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
result.method == KEY_STORE_METHOD_NONE);
g_logged_in = TRUE;
g_print("[login] New identity: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
if (result.method == KEY_STORE_METHOD_NONE) {
g_print("[login] No-login mode (browsing without Nostr identity)\n");
} else {
g_print("[login] New identity: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
return 0;
}
/* ---- Main ------------------------------------------------------------ */
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
/* Ignore SIGPIPE — n_signer transports (qrexec, unix, tcp) may close
* pipes abruptly, and we don't want that to kill the browser. */
(void)signal(SIGPIPE, SIG_IGN);
/* Parse CLI flags BEFORE gtk_init() so GTK doesn't abort on our
* flags. cli_parse() strips recognized flags from argv and collects
* positional URLs. */
cli_args_t cli;
if (cli_parse(&argc, &argv, &cli) != 0) {
cli_args_free(&cli);
return EXIT_FAILURE;
}
if (cli.want_help) {
cli_print_usage(stdout);
cli_args_free(&cli);
return EXIT_SUCCESS;
}
if (cli.want_version) {
printf("sovereign_browser %s\n", SB_VERSION);
cli_args_free(&cli);
return EXIT_SUCCESS;
}
gtk_init(&argc, &argv);
/* Load URL history from disk. */
/* Load settings and history from disk. */
settings_load();
history_load();
const char *start_url = (argc > 1) ? argv[1] : "https://example.com";
char *normalized = normalize_url(start_url);
/* Initialize the SQLite database for Nostr events and misc data. */
db_init();
/* Apply CLI overrides to the in-memory settings singleton. These
* do not write to disk — they are one-shot overrides for this run. */
{
browser_settings_t *s = settings_get_mutable();
if (cli.new_tab_url) {
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
cli.new_tab_url);
}
if (cli.max_tabs > 0) {
s->max_tabs = cli.max_tabs;
}
if (cli.session_restore == CLI_TRISTATE_TRUE) {
s->restore_session = TRUE;
} else if (cli.session_restore == CLI_TRISTATE_FALSE) {
s->restore_session = FALSE;
}
if (cli.agent_port > 0) {
s->agent_server_port = cli.agent_port;
}
if (cli.agent_enabled == CLI_TRISTATE_TRUE) {
s->agent_server_enabled = TRUE;
} else if (cli.agent_enabled == CLI_TRISTATE_FALSE) {
s->agent_server_enabled = FALSE;
}
if (cli.login_timeout_ms > 0) {
s->agent_login_timeout_ms = cli.login_timeout_ms;
}
/* --agent-origin: append to allowed origins (comma-separated). */
if (cli.agent_origin_count > 0) {
GString *origins = g_string_new(s->agent_allowed_origins);
for (int i = 0; i < cli.agent_origin_count; i++) {
if (origins->len > 0) g_string_append_c(origins, ',');
g_string_append(origins, cli.agent_origins[i]);
}
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins),
"%s", origins->str);
g_string_free(origins, TRUE);
}
}
/* Top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
/* Login before showing the browser. */
if (do_login(GTK_WINDOW(window)) != 0) {
/* User cancelled login — exit without gtk_main_quit (we haven't
* entered gtk_main yet, so calling gtk_main_quit would crash). */
g_print("[login] Cancelled, exiting.\n");
nostr_cleanup();
/* Start the agent server before login. The server runs on the
* configured port (default 17777) and is available throughout the
* entire browser lifecycle — an agent can log in at any time, even
* while the GTK login dialog is showing or after the browser is
* already running. If disabled in settings, skip it. */
const browser_settings_t *s = settings_get();
if (s->agent_server_enabled) {
if (agent_server_start(s->agent_server_port) == 0) {
g_print("[agent] Server started on port %d\n",
agent_server_get_port());
} else {
g_printerr("[agent] Failed to start server on port %d\n",
s->agent_server_port);
}
}
/* Initialize nostr_core_lib (needed for both agent and GTK login). */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
agent_server_stop();
cli_args_free(&cli);
return EXIT_SUCCESS;
}
/* Vertical box: toolbar on top, web view below. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* Set up the login callback so if an agent calls 'login' while the
* GTK dialog is showing, we can close the dialog and proceed. */
agent_server_set_login_callback(agent_login_callback);
WebKitWebView *webview = WEBKIT_WEB_VIEW(webkit_web_view_new());
/* If --no-login was given, skip login entirely. The browser works
* as a normal browser without a Nostr identity. window.nostr won't
* be available for sign requests, but all browsing works. */
if (cli.no_login) {
g_logged_in = TRUE;
g_state.readonly = TRUE;
g_print("[login] No-login mode (browsing without Nostr identity)\n");
}
/* ── Security strip: disable web security restrictions ───────
* The "reckless browser" thesis: identity and transport are handled
* at a different layer (Nostr keys + FIPS mesh), so the browser's
* own security sandbox (CORS, SOP, TLS cert enforcement, mixed
* content blocking) gets in the way and is deliberately removed.
*/
/* If --login-method was given on the command line, perform CLI login
* now and skip the GTK dialog entirely. This reuses agent_login(),
* the same code path as the MCP 'login' tool. */
if (cli.login_method) {
if (cli_login(&cli) != 0) {
g_printerr("[login] CLI login failed, exiting.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
g_logged_in = TRUE;
}
/* Enable developer extras (Web Inspector) for debugging. */
WebKitSettings *settings = webkit_web_view_get_settings(webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
/* Show the GTK login dialog immediately — no blocking wait.
* The dialog runs a nested GTK main loop (gtk_dialog_run) which
* processes WebSocket events, so the agent server is live during
* the dialog. If an agent calls 'login' while the dialog is open,
* the callback fires and we close the dialog.
*
* If the agent logs in before the dialog appears (unlikely but
* possible), skip the dialog entirely. */
if (!g_logged_in) {
if (do_login(g_window) != 0) {
g_print("[login] Cancelled, exiting.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_SUCCESS;
}
}
/* Allow file:// pages to access other file:// resources and make
* cross-origin requests (needed for our test page to call
* sovereign://). */
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
/* If the user logged in with a Nostr identity (not --no-login), start
* a background thread to fetch their kind 0/3/10002 events from the
* bootstrap relays. The results are cached in the SQLite database.
* The thread frees the pubkey copy when done. */
if (g_state.pubkey_hex[0] != '\0') {
char *pubkey_copy = g_strdup(g_state.pubkey_hex);
g_thread_new("relay-fetch", relay_fetch_thread, pubkey_copy);
g_print("[relay] Bootstrap fetch thread started for %s\n",
g_state.pubkey_hex);
}
/* Disable mixed content blocking — allow HTTP resources on HTTPS
* pages (FIPS/nostr:// content may not use TLS). */
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */
WebKitWebContext *web_ctx = webkit_web_context_get_default();
/* Get the web context for scheme + security manager registration. */
WebKitWebContext *web_ctx = webkit_web_view_get_context(webview);
/* Register sovereign:// as a secure, local, CORS-enabled scheme so
* that fetch() from any page can call it without cross-origin
* restrictions. */
WebKitSecurityManager *sec_mgr = webkit_web_context_get_security_manager(web_ctx);
/* ── Security strip: disable web security restrictions ─────── */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "sovereign");
/* Also register file:// as secure so local pages can call our bridge. */
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr,
"sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr = webkit_web_context_get_website_data_manager(web_ctx);
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Register the sovereign:// URI scheme for the window.nostr bridge
* and browser-internal pages (sovereign://security). */
/* Enable the favicon database so WebKitGTK automatically fetches and
* caches favicons for visited pages. Without this, the
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
* always returns NULL. Passing NULL for the directory uses WebKit's
* default cache location. */
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
g_print("[main] Favicon database enabled\n");
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
nostr_bridge_set_security_refs(settings, sec_mgr);
/* Inject window.nostr into every page before page scripts run. */
nostr_inject_setup(webview);
/* Vertical box: the tab manager's notebook fills the window. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* Toolbar: [hamburger] [url entry], horizontal. */
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(vbox), toolbar, FALSE, FALSE, 0);
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
GtkWidget *hamburger = build_hamburger_menu(window, webview);
gtk_box_pack_start(GTK_BOX(toolbar), hamburger, FALSE, FALSE, 0);
GtkWidget *url_entry = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(url_entry),
normalized ? normalized : start_url);
gtk_box_pack_start(GTK_BOX(toolbar), url_entry, TRUE, TRUE, 0);
/* Store webview reference on the window for menu callbacks. */
g_object_set_data(G_OBJECT(window), "webview", webview);
gtk_box_pack_start(GTK_BOX(vbox), GTK_WIDGET(webview), TRUE, TRUE, 0);
/* Wire signals. */
g_signal_connect(url_entry, "activate", G_CALLBACK(on_url_activate),
webview);
g_signal_connect(webview, "load-changed", G_CALLBACK(on_load_changed),
url_entry);
g_signal_connect(webview, "load-failed", G_CALLBACK(on_load_failed),
NULL);
/* Load the start URL. */
if (normalized != NULL) {
webkit_web_view_load_uri(webview, normalized);
g_free(normalized);
/* Decide whether to restore the previous session or open CLI URLs.
*
* Precedence:
* 1. --no-session-restore → skip restore, open CLI URLs (or default)
* 2. --url / positional → skip restore, open the given URLs
* 3. --session-restore → force restore
* 4. settings.restore_session (default) → restore if enabled
*
* If restore succeeds, CLI URLs are ignored (the user wanted their
* session back). If restore fails or is skipped, open CLI URLs; if
* none were given, open a single tab with the default new-tab URL. */
gboolean skip_restore = (cli.session_restore == CLI_TRISTATE_FALSE) ||
(cli.url_count > 0);
int restored = skip_restore ? 0 : session_restore();
if (restored == 0) {
if (cli.url_count > 0) {
for (int i = 0; i < cli.url_count; i++) {
tab_manager_new_tab(cli.urls[i]);
}
} else {
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
}
}
/* Wire the security refs to the first tab's settings (settings are
* per-webview, not per-context). The sovereign://security page uses
* these to read and toggle security features. */
{
tab_info_t *first_tab = tab_manager_get(0);
if (first_tab && first_tab->webview) {
WebKitSettings *settings =
webkit_web_view_get_settings(first_tab->webview);
nostr_bridge_set_security_refs(settings, sec_mgr);
}
}
cli_args_free(&cli);
gtk_widget_show_all(window);
gtk_main();
+671 -44
View File
@@ -8,12 +8,18 @@
#include "nostr_bridge.h"
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <stdlib.h>
#include <stdio.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip019.h"
#include "nostr_core/nip021.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include "settings.h"
#include "tab_manager.h"
#include "qr.h"
#include "db.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
@@ -93,6 +99,22 @@ static void respond_error_json(WebKitURISchemeRequest *request,
g_object_unref(stream);
}
/* Finish a scheme request with raw bytes and a given MIME type.
* Takes ownership of the GBytes (unrefs it after finishing). */
static void respond_bytes(WebKitURISchemeRequest *request, GBytes *bytes,
const char *mime_type) {
if (bytes == NULL) {
respond_error_json(request, -1, "Failed to generate content");
return;
}
gsize len = g_bytes_get_size(bytes);
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
g_bytes_unref(bytes);
webkit_uri_scheme_request_finish(request, stream, len, mime_type);
g_object_unref(stream);
}
/* ── Method handlers ────────────────────────────────────────────── */
static void handle_get_public_key(WebKitURISchemeRequest *request) {
@@ -337,11 +359,38 @@ static void handle_get_relays(WebKitURISchemeRequest *request) {
free(json);
}
/* ── Security settings page ────────────────────────────────────── */
/* ── Settings page ─────────────────────────────────────────────── */
/* Helper: extract a query parameter value (key=...) from a query string.
* Returns a newly allocated string (g_free) or NULL if not found. */
static char *query_param(const char *query, const char *key) {
if (query == NULL || key == NULL) return NULL;
size_t klen = strlen(key);
const char *p = query;
while (*p) {
if (strncmp(p, key, klen) == 0 && p[klen] == '=') {
const char *v = p + klen + 1;
const char *end = strchr(v, '&');
size_t vlen = end ? (size_t)(end - v) : strlen(v);
char *val = g_malloc(vlen + 1);
memcpy(val, v, vlen);
val[vlen] = '\0';
/* URL-decode. */
char *decoded = g_uri_unescape_string(val, NULL);
g_free(val);
return decoded;
}
/* Advance to next param. */
const char *amp = strchr(p, '&');
if (amp == NULL) break;
p = amp + 1;
}
return NULL;
}
/* Generate the sovereign://settings HTML page. */
static void handle_settings_page(WebKitURISchemeRequest *request) {
/* Read current settings state. */
/* Read current WebKit security settings state. */
int dev_extras = g_bridge.settings ?
webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0;
int file_access = g_bridge.settings ?
@@ -349,6 +398,54 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
int universal_access = g_bridge.settings ?
webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings) : 0;
/* Read browser preferences. */
const browser_settings_t *bs = settings_get();
const char *pos_str = "top";
switch (bs->tab_bar_position) {
case GTK_POS_TOP: pos_str = "top"; break;
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
case GTK_POS_LEFT: pos_str = "left"; break;
case GTK_POS_RIGHT: pos_str = "right"; break;
}
/* Build a nostr:npub1... URI for the QR code in the Identity section.
* Convert the hex pubkey to 32 bytes, then use nip021 to build the URI.
* If no identity is loaded, skip the QR code. */
char npub_uri[160] = "";
char qr_img_html[512] = "";
if (g_bridge.pubkey_hex[0] != '\0') {
unsigned char pubkey_bytes[32] = {0};
int valid = 1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
valid = 0;
break;
}
pubkey_bytes[i] = (unsigned char)byte;
}
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
sizeof(npub_uri)) == 0) {
/* URL-encode the nostr: URI for use in an <img src> query. */
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
snprintf(qr_img_html, sizeof(qr_img_html),
" <div style='text-align:center; margin: 12px 0;'>"
"<img src='sovereign://qr?text=%s&box_size=6&border=4' "
"alt='npub QR code' style='image-rendering: pixelated; "
"border: 4px solid #fff; border-radius: 4px;'>"
"<div class='info' style='margin-top: 6px;'>"
"Scan with a Nostr client to load this identity</div>"
"</div>\n",
enc);
g_free(enc);
}
}
/* HTML-escape the bootstrap relays for the <textarea>. Newlines are
* preserved (they're valid in textarea content), but <, >, & are
* escaped to prevent breaking the HTML. */
char *relays_escaped = g_markup_escape_text(bs->bootstrap_relays, -1);
/* Build the HTML page. */
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
@@ -373,6 +470,18 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" width: 20px; height: 20px; border-radius: 50%%; background: #e0e0e0;\n"
" transition: transform 0.2s; }\n"
" .toggle.on::after { transform: translateX(26px); }\n"
" .field { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .field input, .field select {\n"
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
" font-size: 13px; min-width: 200px; }\n"
" .field input:focus, .field select:focus { border-color: #ff4444; }\n"
" .save-btn { background: #ff4444; color: #fff; border: none;\n"
" border-radius: 4px; padding: 6px 16px; cursor: pointer;\n"
" font-family: monospace; font-size: 13px; margin-left: 8px; }\n"
" .save-btn:hover { background: #ff6666; }\n"
" .status { padding: 8px; margin: 10px 0; border-radius: 4px; display: none; }\n"
" .status.show { display: block; }\n"
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
@@ -388,6 +497,104 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" <div class='info-row'><span>Pubkey</span><span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>Method</span><span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span><span class='info'>%s</span></div>\n"
"%s"
"\n"
"<h2>Tabs</h2>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Restore session on startup</div>\n"
" <div class='setting-desc'>Reopen tabs from the previous session</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"restore_session\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>New tab URL</div>\n"
" <div class='setting-desc'>Page loaded when opening a new tab</div></div>\n"
" <div><input type='text' id='new_tab_url' value='%s'>\n"
" <button class='save-btn' onclick=\"save('new_tab_url')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Tab bar position</div>\n"
" <div class='setting-desc'>Where the tab strip appears</div></div>\n"
" <div><select id='tab_bar_position'>\n"
" <option value='top'%s>Top</option>\n"
" <option value='bottom'%s>Bottom</option>\n"
" <option value='left'%s>Left</option>\n"
" <option value='right'%s>Right</option>\n"
" </select>\n"
" <button class='save-btn' onclick=\"save('tab_bar_position')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Show tab close buttons</div>\n"
" <div class='setting-desc'>Per-tab close button in the tab strip</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"show_tab_close_buttons\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Middle-click to close tab</div>\n"
" <div class='setting-desc'>Middle-click on a tab closes it</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"middle_click_close\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Ctrl+Tab to switch tabs</div>\n"
" <div class='setting-desc'>Cycle tabs with Ctrl+Tab / Ctrl+Shift+Tab</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"ctrl_tab_switch\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Allow drag to reorder tabs</div>\n"
" <div class='setting-desc'>Drag tabs in the strip to reorder them</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"tab_drag_reorder\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Maximum tabs</div>\n"
" <div class='setting-desc'>Hard limit on simultaneous tabs</div></div>\n"
" <div><input type='number' id='max_tabs' value='%d' min='1' max='999'>\n"
" <button class='save-btn' onclick=\"save('max_tabs')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Agent Server</h2>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Enable agent server</div>\n"
" <div class='setting-desc'>MCP server for automation (takes effect on next launch)</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"agent_server_enabled\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Agent server port</div>\n"
" <div class='setting-desc'>TCP port for the MCP server (next launch)</div></div>\n"
" <div><input type='number' id='agent_server_port' value='%d' min='1' max='65535'>\n"
" <button class='save-btn' onclick=\"save('agent_server_port')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Allowed origins</div>\n"
" <div class='setting-desc'>Comma-separated CORS origins (* for any)</div></div>\n"
" <div><input type='text' id='agent_allowed_origins' value='%s'>\n"
" <button class='save-btn' onclick=\"save('agent_allowed_origins')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Login timeout (ms)</div>\n"
" <div class='setting-desc'>Wait for agent login before GTK dialog</div></div>\n"
" <div><input type='number' id='agent_login_timeout_ms' value='%d' min='0' max='300000'>\n"
" <button class='save-btn' onclick=\"save('agent_login_timeout_ms')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Bootstrap Relays</h2>\n"
"<p class='note'>Relays queried after login to fetch your profile (kind 0),\n"
"contacts (kind 3), and relay list (kind 10002). One wss:// URL per line.</p>\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Relay URLs</div>\n"
" <div class='setting-desc'>One wss:// URL per line</div></div>\n"
" <div><textarea id='bootstrap_relays' rows='4' cols='40'>%s</textarea>\n"
" <button class='save-btn' onclick=\"save('bootstrap_relays')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Security</h2>\n"
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
@@ -427,31 +634,62 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
"<div id='status' class='status'></div>\n"
"\n"
"<script>\n"
" function showStatus(cls, msg) {\n"
" var s = document.getElementById('status');\n"
" s.className = 'status ' + cls + ' show';\n"
" s.textContent = msg;\n"
" }\n"
" function toggle(feature) {\n"
" fetch('sovereign://settings/set?feature=' + feature)\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" var s = document.getElementById('status');\n"
" if (d.error) {\n"
" s.className = 'status err show';\n"
" s.textContent = d.message;\n"
" showStatus('err', d.message);\n"
" } else {\n"
" s.className = 'status ok show';\n"
" s.textContent = d.feature + ': ' + (d.enabled ? 'ON' : 'OFF');\n"
" showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF'));\n"
" setTimeout(() => location.reload(), 500);\n"
" }\n"
" })\n"
" .catch(e => {\n"
" var s = document.getElementById('status');\n"
" s.className = 'status err show';\n"
" s.textContent = 'Error: ' + e.message;\n"
" });\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
" function save(key) {\n"
" var el = document.getElementById(key);\n"
" var val = el ? el.value : '';\n"
" fetch('sovereign://settings/set?key=' + encodeURIComponent(key)\n"
" + '&value=' + encodeURIComponent(val))\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" if (d.error) {\n"
" showStatus('err', d.message);\n"
" } else {\n"
" showStatus('ok', d.key + ' = ' + d.value);\n"
" if (d.reload) setTimeout(() => location.reload(), 500);\n"
" }\n"
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"</script>\n"
"</body></html>\n",
g_bridge.pubkey_hex[0] ? g_bridge.pubkey_hex : "(none)",
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
qr_img_html,
bs->restore_session ? "on" : "off",
bs->new_tab_url,
strcmp(pos_str, "top") == 0 ? " selected" : "",
strcmp(pos_str, "bottom") == 0 ? " selected" : "",
strcmp(pos_str, "left") == 0 ? " selected" : "",
strcmp(pos_str, "right") == 0 ? " selected" : "",
bs->show_tab_close_buttons ? "on" : "off",
bs->middle_click_close ? "on" : "off",
bs->ctrl_tab_switch ? "on" : "off",
bs->tab_drag_reorder ? "on" : "off",
bs->max_tabs,
bs->agent_server_enabled ? "on" : "off",
bs->agent_server_port,
bs->agent_allowed_origins,
bs->agent_login_timeout_ms,
relays_escaped,
dev_extras ? "on" : "off",
file_access ? "on" : "off",
universal_access ? "on" : "off"
@@ -459,59 +697,405 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
respond_html(request, html);
g_free(html);
g_free(relays_escaped);
}
/* Handle sovereign://settings/set?feature=X — toggle a setting. */
/* Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y). */
static void handle_settings_set(WebKitURISchemeRequest *request,
const char *query) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "Settings not available");
browser_settings_t *bs = settings_get_mutable();
/* ── Toggle path: ?feature=X ──────────────────────────────────── */
char *feature = query_param(query, "feature");
if (feature != NULL) {
int new_state = 0;
int reload = 0;
if (strcmp(feature, "dev_extras") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
} else if (strcmp(feature, "file_access") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "universal_access") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "restore_session") == 0) {
bs->restore_session = !bs->restore_session;
new_state = bs->restore_session;
settings_save();
} else if (strcmp(feature, "show_tab_close_buttons") == 0) {
bs->show_tab_close_buttons = !bs->show_tab_close_buttons;
new_state = bs->show_tab_close_buttons;
settings_save();
tab_manager_apply_settings();
} else if (strcmp(feature, "middle_click_close") == 0) {
bs->middle_click_close = !bs->middle_click_close;
new_state = bs->middle_click_close;
settings_save();
} else if (strcmp(feature, "ctrl_tab_switch") == 0) {
bs->ctrl_tab_switch = !bs->ctrl_tab_switch;
new_state = bs->ctrl_tab_switch;
settings_save();
} else if (strcmp(feature, "tab_drag_reorder") == 0) {
bs->tab_drag_reorder = !bs->tab_drag_reorder;
new_state = bs->tab_drag_reorder;
settings_save();
tab_manager_apply_settings();
} else if (strcmp(feature, "agent_server_enabled") == 0) {
bs->agent_server_enabled = !bs->agent_server_enabled;
new_state = bs->agent_server_enabled;
settings_save();
reload = 1;
} else {
respond_error_json(request, -1, "Unknown feature");
g_free(feature);
return;
}
g_print("[settings] %s: %s\n", feature, new_state ? "ON" : "OFF");
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", feature);
cJSON_AddBoolToObject(result, "value", new_state);
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(feature);
return;
}
/* Parse feature= from query string. */
const char *feat = strstr(query, "feature=");
if (feat == NULL) {
respond_error_json(request, -1, "Missing feature parameter");
/* ── Key/value path: ?key=X&value=Y ───────────────────────────── */
char *key = query_param(query, "key");
char *value = query_param(query, "value");
if (key == NULL || value == NULL) {
respond_error_json(request, -1, "Missing key or value parameter");
g_free(key);
g_free(value);
return;
}
feat += 8; /* skip "feature=" */
/* Extract feature name (up to & or end). */
char feature[64];
size_t i = 0;
while (feat[i] && feat[i] != '&' && i < sizeof(feature) - 1) {
feature[i] = feat[i];
i++;
}
feature[i] = '\0';
int reload = 0;
/* Toggle the setting. */
int new_state = 0;
if (strcmp(feature, "dev_extras") == 0) {
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
} else if (strcmp(feature, "file_access") == 0) {
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "universal_access") == 0) {
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
if (strcmp(key, "new_tab_url") == 0) {
snprintf(bs->new_tab_url, sizeof(bs->new_tab_url), "%s", value);
settings_save();
} else if (strcmp(key, "tab_bar_position") == 0) {
if (strcasecmp(value, "top") == 0) bs->tab_bar_position = GTK_POS_TOP;
else if (strcasecmp(value, "bottom") == 0) bs->tab_bar_position = GTK_POS_BOTTOM;
else if (strcasecmp(value, "left") == 0) bs->tab_bar_position = GTK_POS_LEFT;
else if (strcasecmp(value, "right") == 0) bs->tab_bar_position = GTK_POS_RIGHT;
else {
respond_error_json(request, -1, "Invalid position value");
g_free(key); g_free(value);
return;
}
settings_save();
tab_manager_apply_settings();
} else if (strcmp(key, "max_tabs") == 0) {
int v = atoi(value);
if (v < 1) v = 1;
bs->max_tabs = v;
settings_save();
} else if (strcmp(key, "agent_server_port") == 0) {
int v = atoi(value);
if (v < 1 || v > 65535) {
respond_error_json(request, -1, "Port out of range (1-65535)");
g_free(key); g_free(value);
return;
}
bs->agent_server_port = v;
settings_save();
reload = 1;
} else if (strcmp(key, "agent_allowed_origins") == 0) {
snprintf(bs->agent_allowed_origins,
sizeof(bs->agent_allowed_origins), "%s", value);
settings_save();
reload = 1;
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
int v = atoi(value);
if (v < 0) v = 0;
bs->agent_login_timeout_ms = v;
settings_save();
} else if (strcmp(key, "bootstrap_relays") == 0) {
/* The textarea value uses \n for newlines (URL-encoded as %0A
* by the browser, decoded by query_param). Store as-is. */
snprintf(bs->bootstrap_relays,
sizeof(bs->bootstrap_relays), "%s", value);
settings_save();
} else {
respond_error_json(request, -1, "Unknown feature");
respond_error_json(request, -1, "Unknown key");
g_free(key); g_free(value);
return;
}
g_print("[security] %s: %s\n", feature, new_state ? "ON" : "OFF");
g_print("[settings] %s = %s\n", key, value);
/* Return the new state. */
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "feature", feature);
cJSON_AddBoolToObject(result, "enabled", new_state);
cJSON_AddStringToObject(result, "key", key);
cJSON_AddStringToObject(result, "value", value);
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(key);
g_free(value);
}
/* ── Profile page (sovereign://profile) ──────────────────────────── */
/* Generate the sovereign://profile HTML page.
* Reads the user's kind 0 (profile) and kind 3 (contacts) events from
* the SQLite database and displays them. This is useful for verifying
* that the post-login relay fetch worked correctly. */
static void handle_profile_page(WebKitURISchemeRequest *request) {
if (g_bridge.pubkey_hex[0] == '\0') {
respond_html(request,
"<!DOCTYPE html><html><head><meta charset='UTF-8'>"
"<style>body{font-family:monospace;max-width:700px;margin:40px auto;"
"padding:20px;background:#1a1a1a;color:#e0e0e0;}"
"h1{color:#ff4444;border-bottom:2px solid #ff4444;padding-bottom:8px;}"
".note{color:#888;}</style></head><body>"
"<h1>sovereign browser — Profile</h1>"
"<p class='note'>No identity loaded. Log in to see your profile.</p>"
"</body></html>");
return;
}
/* Fetch kind 0 (profile metadata) and kind 3 (contacts) from SQLite. */
cJSON *kind0 = db_get_latest_event(g_bridge.pubkey_hex, 0);
cJSON *kind3 = db_get_latest_event(g_bridge.pubkey_hex, 3);
/* Parse kind 0 content — it's a JSON object with name, about, picture, etc. */
const char *name = "", *about = "", *picture = "", *display_name = "";
const char *website = "", *lud16 = "";
const char *kind0_created = "";
cJSON *meta = NULL;
if (kind0) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
meta = cJSON_Parse(content->valuestring);
if (meta) {
cJSON *item;
item = cJSON_GetObjectItemCaseSensitive(meta, "name");
if (cJSON_IsString(item)) name = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "display_name");
if (cJSON_IsString(item)) display_name = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "about");
if (cJSON_IsString(item)) about = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "picture");
if (cJSON_IsString(item)) picture = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "website");
if (cJSON_IsString(item)) website = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "lud16");
if (cJSON_IsString(item)) lud16 = item->valuestring;
}
}
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind0, "created_at");
if (cJSON_IsNumber(ca)) {
static char ca_buf[32];
snprintf(ca_buf, sizeof(ca_buf), "%ld", (long)ca->valuedouble);
kind0_created = ca_buf;
}
}
/* Parse kind 3 tags — p tags are followed pubkeys. */
int contact_count = 0;
const char *kind3_created = "";
if (kind3) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(kind3, "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, "p") == 0) {
contact_count++;
}
}
}
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind3, "created_at");
if (cJSON_IsNumber(ca)) {
static char ca3_buf[32];
snprintf(ca3_buf, sizeof(ca3_buf), "%ld", (long)ca->valuedouble);
kind3_created = ca3_buf;
}
}
/* Count events in the database for this pubkey. */
int k0_count = db_count_events(g_bridge.pubkey_hex, 0);
int k3_count = db_count_events(g_bridge.pubkey_hex, 3);
int k10002_count = db_count_events(g_bridge.pubkey_hex, 10002);
/* Build the npub URI for the QR code. */
char npub_uri[160] = "";
char *qr_html = g_strdup("");
if (g_bridge.pubkey_hex[0]) {
unsigned char pubkey_bytes[32] = {0};
int valid = 1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
valid = 0; break;
}
pubkey_bytes[i] = (unsigned char)byte;
}
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
sizeof(npub_uri)) == 0) {
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
g_free(qr_html);
qr_html = g_strdup_printf(
"<div class='qr'><img src='sovereign://qr?text=%s"
"&box_size=6&border=4' alt='npub QR code'></div>\n", enc);
g_free(enc);
}
}
/* Escape strings for HTML. */
char *esc_name = g_markup_escape_text(name, -1);
char *esc_dname = g_markup_escape_text(display_name, -1);
char *esc_about = g_markup_escape_text(about, -1);
char *esc_picture = g_markup_escape_text(picture, -1);
char *esc_website = g_markup_escape_text(website, -1);
char *esc_lud16 = g_markup_escape_text(lud16, -1);
char *esc_npub = g_markup_escape_text(npub_uri, -1);
/* Build optional sub-sections. */
char *picture_html = picture[0] ?
g_strdup_printf("<img src='%s' alt='Profile picture' "
"style='max-width:120px;max-height:120px;border-radius:8px;"
"float:right;margin-left:16px;'>", esc_picture) :
g_strdup("");
char *about_html = about[0] ?
g_strdup_printf("<div class='info-row'><span>About</span>"
"<span class='info' style='max-width:400px;'>%s</span></div>\n",
esc_about) :
g_strdup("");
const char *k0_missing = kind0 ? "" :
"<p class='missing'>(no kind 0 event in database)</p>\n";
const char *k3_missing = kind3 ? "" :
"<p class='missing'>(no kind 3 event in database)</p>\n";
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Profile</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
" .info { color: #888; font-size: 12px; word-break: break-all; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
" .missing { color: #5a2a2a; font-style: italic; }\n"
" .qr { text-align: center; margin: 12px 0; }\n"
" .qr img { image-rendering: pixelated; border: 4px solid #fff; border-radius: 4px; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Profile</h1>\n"
"%s"
"<h2>Identity</h2>\n"
" <div class='info-row'><span>Pubkey</span>"
"<span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>npub URI</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Method</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span>"
"<span class='info'>%s</span></div>\n"
"%s"
"\n"
"<h2>Profile (kind 0)</h2>\n"
"%s"
" <div class='info-row'><span>Name</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Display Name</span>"
"<span class='info'>%s</span></div>\n"
"%s"
" <div class='info-row'><span>Website</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Lightning (lud16)</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Last updated</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<h2>Contacts (kind 3)</h2>\n"
"%s"
" <div class='info-row'><span>Following</span>"
"<span class='info'>%d pubkey(s)</span></div>\n"
" <div class='info-row'><span>Last updated</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<h2>Relay List (kind 10002)</h2>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<p class='note'>This page shows data cached in the local SQLite database "
"from the bootstrap relay fetch. If fields show <span class='missing'>"
"(not found)</span>, the relay fetch hasn't completed or the user has no "
"events of that kind on the bootstrap relays.</p>\n"
"</body></html>\n",
picture_html,
g_bridge.pubkey_hex,
esc_npub,
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
qr_html,
k0_missing,
esc_name,
esc_dname,
about_html,
esc_website,
esc_lud16,
kind0_created[0] ? kind0_created : "(not found)",
k0_count,
k3_missing,
contact_count,
kind3_created[0] ? kind3_created : "(not found)",
k3_count,
k10002_count
);
respond_html(request, html);
g_free(html);
g_free(qr_html);
g_free(esc_name);
g_free(esc_dname);
g_free(esc_about);
g_free(esc_picture);
g_free(esc_website);
g_free(esc_lud16);
g_free(esc_npub);
g_free(picture_html);
g_free(about_html);
if (meta) cJSON_Delete(meta);
if (kind0) cJSON_Delete(kind0);
if (kind3) cJSON_Delete(kind3);
}
/* ── URI scheme callback ────────────────────────────────────────── */
@@ -528,6 +1112,49 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
return;
}
/* Route sovereign://qr?text=... — render a QR code as PNG.
* Optional query params:
* text (required) — the string to encode (URL-decoded).
* box_size (optional) — pixels per module (default 8).
* border (optional) — quiet-zone modules (default 4). */
if (strcmp(uri, "sovereign://qr") == 0 ||
strncmp(uri, "sovereign://qr?", 15) == 0) {
const char *q = strchr(uri, '?');
char *text = query_param(q ? q + 1 : "", "text");
if (text == NULL || text[0] == '\0') {
respond_error_json(request, -1, "Missing 'text' parameter");
g_free(text);
return;
}
char *bs_str = query_param(q ? q + 1 : "", "box_size");
char *bd_str = query_param(q ? q + 1 : "", "border");
int box_size = bs_str ? atoi(bs_str) : 8;
int border = bd_str ? atoi(bd_str) : 4;
if (box_size < 1) box_size = 1;
if (box_size > 32) box_size = 32;
if (border < 0) border = 0;
if (border > 16) border = 16;
GBytes *png = qr_render_png(text, box_size, border);
if (png == NULL) {
respond_error_json(request, -1,
"Failed to encode QR code (text too long?)");
} else {
respond_bytes(request, png, "image/png");
}
g_free(text);
g_free(bs_str);
g_free(bd_str);
return;
}
/* Route sovereign://profile — show the user's Nostr profile from
* cached kind 0/3 events in the SQLite database. */
if (strcmp(uri, "sovereign://profile") == 0) {
handle_profile_page(request);
return;
}
/* Route sovereign://settings requests. */
if (strcmp(uri, "sovereign://settings") == 0 ||
strncmp(uri, "sovereign://settings?", 21) == 0) {
+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 */
+139
View File
@@ -0,0 +1,139 @@
/*
* 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 <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
/* ── 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 for %s from %d relay(s)...\n",
pubkey_hex, relay_count);
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002]} */
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_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. */
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
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);
return stored;
}
/* ── 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 */
+108
View File
@@ -0,0 +1,108 @@
/*
* session.c — tab session save/restore for sovereign_browser
*
* Saves open tab URLs to ~/.sovereign_browser/session.txt (one per line)
* and restores them on startup if settings.restore_session is true.
*/
#include "session.h"
#include "settings.h"
#include "tab_manager.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#define SESSION_URL_MAX 2048
/* ── Path helper ──────────────────────────────────────────────────── */
static int session_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/session.txt", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Save ─────────────────────────────────────────────────────────── */
void session_save(void) {
char path[512];
if (session_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
int count = tab_manager_count();
for (int i = 0; i < count; i++) {
const char *url = tab_manager_get_url(i);
if (url != NULL && url[0] != '\0') {
fprintf(f, "%s\n", url);
}
}
fclose(f);
g_print("[session] Saved %d tab(s)\n", count);
}
/* ── Restore ──────────────────────────────────────────────────────── */
int session_restore(void) {
const browser_settings_t *s = settings_get();
if (!s->restore_session) {
return 0;
}
char path[512];
if (session_path(path, sizeof(path)) != 0) {
return 0;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return 0;
}
int restored = 0;
char line[SESSION_URL_MAX];
while (fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
}
if (len == 0) continue;
int index = tab_manager_new_tab(line);
if (index >= 0) {
restored++;
}
}
fclose(f);
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 */
+257
View File
@@ -0,0 +1,257 @@
/*
* settings.c — browser preferences for sovereign_browser
*
* Persists settings to ~/.sovereign_browser/settings.conf as key=value lines.
*/
#include "settings.h"
#include <gtk/gtk.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
/* ── 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);
}
/* ── Path helper ──────────────────────────────────────────────────── */
static int settings_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/settings.conf", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── 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);
}
/* ── Load / Save ──────────────────────────────────────────────────── */
void settings_load(void) {
settings_set_defaults(&g_settings);
char path[512];
if (settings_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return;
}
char line[1024];
while (fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
}
if (len == 0 || line[0] == '#') continue;
/* Split key=value. */
char *eq = strchr(line, '=');
if (eq == NULL) continue;
*eq = '\0';
char *key = line;
char *value = eq + 1;
/* Trim whitespace from key. */
while (*key == ' ' || *key == '\t') key++;
char *kend = key + strlen(key);
while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0';
/* Trim whitespace from value. */
while (*value == ' ' || *value == '\t') value++;
if (strcmp(key, "restore_session") == 0) {
g_settings.restore_session = parse_bool(value, g_settings.restore_session);
} else if (strcmp(key, "new_tab_url") == 0) {
snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url),
"%s", value);
} else if (strcmp(key, "tab_bar_position") == 0) {
g_settings.tab_bar_position = parse_position(value, g_settings.tab_bar_position);
} else if (strcmp(key, "show_tab_close_buttons") == 0) {
g_settings.show_tab_close_buttons = parse_bool(value, g_settings.show_tab_close_buttons);
} else if (strcmp(key, "middle_click_close") == 0) {
g_settings.middle_click_close = parse_bool(value, g_settings.middle_click_close);
} else if (strcmp(key, "ctrl_tab_switch") == 0) {
g_settings.ctrl_tab_switch = parse_bool(value, g_settings.ctrl_tab_switch);
} else if (strcmp(key, "max_tabs") == 0) {
g_settings.max_tabs = parse_int(value, g_settings.max_tabs);
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
} else if (strcmp(key, "tab_drag_reorder") == 0) {
g_settings.tab_drag_reorder = parse_bool(value, g_settings.tab_drag_reorder);
} else if (strcmp(key, "agent_server_enabled") == 0) {
g_settings.agent_server_enabled = parse_bool(value, g_settings.agent_server_enabled);
} else if (strcmp(key, "agent_server_port") == 0) {
g_settings.agent_server_port = parse_int(value, g_settings.agent_server_port);
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
} else if (strcmp(key, "agent_allowed_origins") == 0) {
snprintf(g_settings.agent_allowed_origins,
sizeof(g_settings.agent_allowed_origins), "%s", value);
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms);
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
} else if (strcmp(key, "bootstrap_relays") == 0) {
/* The value may contain escaped newlines (\n) since the file
* is line-based key=value. Convert \n to actual newlines. */
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX];
size_t ri = 0, wi = 0;
while (value[ri] && wi < sizeof(buf) - 1) {
if (value[ri] == '\\' && value[ri + 1] == 'n') {
buf[wi++] = '\n';
ri += 2;
} else {
buf[wi++] = value[ri++];
}
}
buf[wi] = '\0';
snprintf(g_settings.bootstrap_relays,
sizeof(g_settings.bootstrap_relays), "%s", buf);
}
/* Unknown keys are silently ignored. */
}
fclose(f);
}
void settings_save(void) {
char path[512];
if (settings_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
const char *pos_str = "top";
switch (g_settings.tab_bar_position) {
case GTK_POS_TOP: pos_str = "top"; break;
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
case GTK_POS_LEFT: pos_str = "left"; break;
case GTK_POS_RIGHT: pos_str = "right"; break;
}
fprintf(f, "# sovereign_browser settings\n");
fprintf(f, "restore_session=%s\n", g_settings.restore_session ? "true" : "false");
fprintf(f, "new_tab_url=%s\n", g_settings.new_tab_url);
fprintf(f, "tab_bar_position=%s\n", pos_str);
fprintf(f, "show_tab_close_buttons=%s\n", g_settings.show_tab_close_buttons ? "true" : "false");
fprintf(f, "middle_click_close=%s\n", g_settings.middle_click_close ? "true" : "false");
fprintf(f, "ctrl_tab_switch=%s\n", g_settings.ctrl_tab_switch ? "true" : "false");
fprintf(f, "max_tabs=%d\n", g_settings.max_tabs);
fprintf(f, "tab_drag_reorder=%s\n", g_settings.tab_drag_reorder ? "true" : "false");
fprintf(f, "agent_server_enabled=%s\n", g_settings.agent_server_enabled ? "true" : "false");
fprintf(f, "agent_server_port=%d\n", g_settings.agent_server_port);
fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins);
fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms);
/* Save bootstrap relays with newlines escaped as \n (the file is
* line-based key=value, so actual newlines would break parsing). */
{
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX * 2];
size_t ri = 0, wi = 0;
while (g_settings.bootstrap_relays[ri] &&
wi < sizeof(buf) - 1) {
if (g_settings.bootstrap_relays[ri] == '\n') {
buf[wi++] = '\\';
buf[wi++] = 'n';
ri++;
} else {
buf[wi++] = g_settings.bootstrap_relays[ri++];
}
}
buf[wi] = '\0';
fprintf(f, "bootstrap_relays=%s\n", buf);
}
fclose(f);
}
const browser_settings_t *settings_get(void) {
return &g_settings;
}
browser_settings_t *settings_get_mutable(void) {
return &g_settings;
}
+74
View File
@@ -0,0 +1,74 @@
/*
* 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"
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 */
} 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 */
+983
View File
@@ -0,0 +1,983 @@
/*
* tab_manager.c — multi-tab management for sovereign_browser
*
* Each tab is a GtkBox page containing a per-tab toolbar (hamburger menu +
* URL entry) and a WebKitWebView. All webviews share the same
* WebKitWebContext so the sovereign:// bridge and security settings apply
* to every tab automatically.
*/
#include "tab_manager.h"
#include "settings.h"
#include "nostr_inject.h"
#include "history.h"
#include "version.h"
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
#include <string.h>
#include <stdlib.h>
/* ── External callbacks defined in main.c ─────────────────────────── *
* These handle identity-related menu actions that need access to the
* global app_state_t (signer, pubkey, method). main.c owns that state.
*/
/* Proxy functions defined in main.c that wrap the app_state_t-aware
* callbacks so they match GTK signal handler signatures. */
extern void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_logout_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data);
extern void app_menu_fips_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
static WebKitWebContext *g_ctx = NULL;
static GtkWindow *g_window = NULL;
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
static tab_info_t **g_tabs = NULL;
static int g_tab_count = 0;
static int g_tab_cap = 0;
/* ── Forward declarations ─────────────────────────────────────────── */
static tab_info_t *tab_create(const char *url);
static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
static char *normalize_url(const char *input);
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
if (strncmp(input, "about:", 6) == 0) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
/* ── Tab label widget ─────────────────────────────────────────────── */
static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)btn;
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
static gboolean on_tab_label_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)widget;
tab_info_t *tab = (tab_info_t *)user_data;
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page);
if (index < 0) return FALSE;
const browser_settings_t *s = settings_get();
/* Middle-click to close. */
if (event->button == 2 && s->middle_click_close) {
tab_manager_close_tab(index);
return TRUE;
}
/* Right-click context menu. */
if (event->button == 3) {
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
g_signal_connect(item_new, "activate",
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
g_signal_connect_swapped(item_close, "activate",
G_CALLBACK(tab_manager_close_tab),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
GtkWidget *item_close_others =
gtk_menu_item_new_with_label("Close Other Tabs");
g_signal_connect_swapped(item_close_others, "activate",
G_CALLBACK(tab_manager_close_others),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
GtkWidget *item_close_right =
gtk_menu_item_new_with_label("Close Tabs to the Right");
g_signal_connect_swapped(item_close_right, "activate",
G_CALLBACK(tab_manager_close_to_right),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
g_signal_connect_swapped(item_dup, "activate",
G_CALLBACK(tab_manager_duplicate),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
g_signal_connect_swapped(item_reload, "activate",
G_CALLBACK(tab_manager_reload),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE;
}
return FALSE;
}
/* Proxy callback for "New Tab" in the context menu — just calls new_tab. */
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Favicon handling ─────────────────────────────────────────────── *
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
* automatically fetches and caches favicons as pages load. It must be
* enabled once on the WebKitWebContext via
* webkit_web_context_set_favicon_database_directory() (done in main.c).
* Once enabled, the webview emits "notify::favicon" when the favicon is
* ready, and webkit_web_view_get_favicon() returns a cairo_surface_t.
*/
static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)pspec;
cairo_surface_t *favicon = webkit_web_view_get_favicon(webview);
if (favicon == NULL) return;
int fav_w = cairo_image_surface_get_width(favicon);
int fav_h = cairo_image_surface_get_height(favicon);
if (fav_w <= 0 || fav_h <= 0) return;
GdkPixbuf *raw = gdk_pixbuf_get_from_surface(favicon, 0, 0, fav_w, fav_h);
if (raw == NULL) return;
GdkPixbuf *pixbuf;
if (fav_w > 16 || fav_h > 16) {
pixbuf = gdk_pixbuf_scale_simple(raw, 16, 16, GDK_INTERP_BILINEAR);
g_object_unref(raw);
} else {
pixbuf = raw;
}
gtk_image_set_from_pixbuf(GTK_IMAGE(tab->favicon), pixbuf);
g_object_unref(pixbuf);
g_print("[favicon] Set favicon (%dx%d) for %s\n",
fav_w, fav_h, tab->current_url);
}
static GtkWidget *build_tab_label(tab_info_t *tab) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
/* Make the tab label expand and fill so tabs divide the available
* space evenly across the tab strip. */
gtk_widget_set_hexpand(box, TRUE);
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
gtk_widget_set_size_request(box, 120, -1); /* min width for readability */
/* Favicon — starts as placeholder, updated via notify::favicon signal. */
tab->favicon = gtk_image_new_from_icon_name("text-html",
GTK_ICON_SIZE_MENU);
gtk_widget_set_valign(tab->favicon, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->favicon, FALSE, FALSE, 0);
/* Title label — expands to fill, ellipsizes if too long. */
tab->title_label = gtk_label_new("New Tab");
gtk_label_set_ellipsize(GTK_LABEL(tab->title_label), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(tab->title_label), 20);
gtk_widget_set_hexpand(tab->title_label, TRUE);
gtk_widget_set_valign(tab->title_label, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->title_label, TRUE, TRUE, 0);
/* Close button. */
const browser_settings_t *s = settings_get();
if (s->show_tab_close_buttons) {
GtkWidget *close_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(close_btn),
gtk_image_new_from_icon_name("window-close-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(close_btn, "Close tab");
g_signal_connect(close_btn, "clicked",
G_CALLBACK(on_tab_close_clicked), tab);
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
}
/* Connect button-press for middle-click and right-click on the label. */
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
g_signal_connect(box, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_widget_show_all(box);
return box;
}
/* ── Per-tab signal handlers ──────────────────────────────────────── */
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
const char *text = gtk_entry_get_text(entry);
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(tab->webview, url);
g_free(url);
}
}
/* Refresh button — left-click: normal reload. */
static void on_refresh_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview) {
webkit_web_view_reload(tab->webview);
}
}
/* Hard reload menu item — bypasses cache. */
static void on_hard_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* Clear site data + hard reload menu item. */
static void on_clear_and_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (!tab || !tab->webview) return;
/* Clear localStorage and sessionStorage via JS. */
const char *clear_js =
"(function(){try{localStorage.clear();}catch(e){}"
"try{sessionStorage.clear();}catch(e){}"
"return 'ok';})()";
char *result = agent_js_eval_sync(tab->webview, clear_js, 3000);
g_free(result);
/* Hard reload to bypass cache. */
webkit_web_view_reload_bypass_cache(tab->webview);
}
/* Refresh button — right-click: show dropdown with hard reload options. */
static gboolean on_refresh_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)widget;
if (event->button == 3) { /* Right-click */
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
g_signal_connect(item_reload, "activate",
G_CALLBACK(on_refresh_clicked), tab);
GtkWidget *item_hard = gtk_menu_item_new_with_label(
"Hard reload (bypass cache)");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_hard);
g_signal_connect(item_hard, "activate",
G_CALLBACK(on_hard_reload), tab);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_clear = gtk_menu_item_new_with_label(
"Clear site data + hard reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_clear);
g_signal_connect(item_clear, "activate",
G_CALLBACK(on_clear_and_reload), tab);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE; /* Suppress default button-press handling */
}
return FALSE; /* Let left-click propagate to "clicked" signal */
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
if (load_event == WEBKIT_LOAD_COMMITTED) {
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
/* Show an empty URL bar for blank pages so the user can
* immediately type a URL. Keep current_url as the real URI
* (about:blank) so session save/duplicate still work. */
if (strcmp(uri, "about:blank") == 0) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
/* Set a temporary title from the URL host while the page loads.
* This ensures the tab shows something meaningful immediately,
* not just "New Tab" or "Loading…". */
const char *host = strstr(uri, "://");
if (host) host += 3;
else host = uri;
/* Strip path — just show the domain. */
char host_buf[256];
snprintf(host_buf, sizeof(host_buf), "%s", host);
char *slash = strchr(host_buf, '/');
if (slash) *slash = '\0';
if (host_buf[0]) {
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook),
tab->page);
if (index >= 0) {
tab_manager_set_title(index, host_buf);
}
}
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
g_print("[loaded] %s -- title: %s\n",
uri ? uri : "(null)",
(title && title[0]) ? title : "(none)");
/* Update tab title. */
if (title && title[0]) {
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook),
tab->page);
if (index >= 0) {
tab_manager_set_title(index, title);
}
}
/* Add to history. */
if (uri != NULL && uri[0] != '\0') {
history_add(uri);
}
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
(void)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
return FALSE;
}
/* ── Hamburger menu callbacks (webview-specific) ──────────────────── */
static void on_menu_reload(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
static void on_menu_stop(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_stop_loading(tab->webview);
}
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (insp) webkit_web_inspector_show(insp);
}
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT, NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL || item == NULL) return;
const char *url = gtk_menu_item_get_label(item);
if (url && url[0]) {
char *normalized = normalize_url(url);
if (normalized) {
webkit_web_view_load_uri(tab->webview, normalized);
g_free(normalized);
}
}
}
static void on_menu_history_clear(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
history_clear();
g_print("[history] cleared\n");
}
static void on_history_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove all existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Rebuild with current history. */
int hcount = history_count();
if (hcount == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no recent pages)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
int show = hcount < 20 ? hcount : 20;
for (int i = 0; i < show; i++) {
const char *url = history_get(i);
if (url == NULL) break;
char label[80];
if (strlen(url) > 75) {
snprintf(label, sizeof(label), "%.72s...", url);
} else {
snprintf(label, sizeof(label), "%s", url);
}
GtkWidget *hitem = gtk_menu_item_new_with_label(label);
gtk_widget_set_tooltip_text(hitem, url);
g_signal_connect(hitem, "activate",
G_CALLBACK(on_menu_history_item), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem);
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *clear_item = gtk_menu_item_new_with_label("Clear Recents");
g_signal_connect(clear_item, "activate",
G_CALLBACK(on_menu_history_clear), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), clear_item);
}
gtk_widget_show_all(menu);
}
/* ── Hamburger menu builder ───────────────────────────────────────── */
static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
(void)tab;
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file), NULL);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), NULL);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Recents submenu. */
GtkWidget *history_menu = gtk_menu_new();
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), NULL);
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — delegated to main.c (app_state_t). */
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate",
G_CALLBACK(app_menu_switch_identity_proxy), g_window);
g_signal_connect(item_lock, "activate",
G_CALLBACK(app_menu_lock_session_proxy), NULL);
g_signal_connect(item_logout, "activate",
G_CALLBACK(app_menu_logout_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(app_menu_security_strip_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate",
G_CALLBACK(app_menu_fips_proxy), NULL);
g_signal_connect(item_nostr, "activate",
G_CALLBACK(app_menu_nostr_sign_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate",
G_CALLBACK(on_menu_inspector), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_settings);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate",
G_CALLBACK(app_menu_about_proxy), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
return button;
}
/* ── Tab array management ─────────────────────────────────────────── */
static int tab_array_add(tab_info_t *tab) {
if (g_tab_count >= g_tab_cap) {
int new_cap = g_tab_cap == 0 ? 8 : g_tab_cap * 2;
tab_info_t **new_arr = g_realloc(g_tabs, new_cap * sizeof(tab_info_t *));
if (new_arr == NULL) return -1;
g_tabs = new_arr;
g_tab_cap = new_cap;
}
g_tabs[g_tab_count] = tab;
return g_tab_count++;
}
static void tab_array_remove(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Free the tab_info_t. */
tab_info_t *tab = g_tabs[index];
if (tab) {
/* The webview and widgets are destroyed by GtkNotebook when the
* page is removed. We just free the struct. */
g_free(tab);
}
/* Shift remaining entries down. */
for (int i = index; i < g_tab_count - 1; i++) {
g_tabs[i] = g_tabs[i + 1];
}
g_tab_count--;
}
/* ── Tab creation ─────────────────────────────────────────────────── */
static tab_info_t *tab_create(const char *url) {
const browser_settings_t *s = settings_get();
tab_info_t *tab = g_new0(tab_info_t, 1);
if (tab == NULL) return NULL;
/* Determine the URL to load. */
const char *load_url = url;
char *default_url = NULL;
if (load_url == NULL || load_url[0] == '\0') {
load_url = s->new_tab_url;
}
default_url = normalize_url(load_url);
if (default_url == NULL) {
default_url = g_strdup(load_url);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", default_url);
snprintf(tab->title, sizeof(tab->title), "Loading…");
/* Create the webview from the shared context. */
tab->webview = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(g_ctx));
/* Enable developer extras + security settings (same as original main.c). */
WebKitSettings *settings = webkit_web_view_get_settings(tab->webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Inject window.nostr into this webview. */
nostr_inject_setup(tab->webview);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
tab->hamburger = build_hamburger_menu(tab);
gtk_box_pack_start(GTK_BOX(toolbar), tab->hamburger, FALSE, FALSE, 0);
/* Refresh button — left-click reloads, right-click shows a menu
* with hard reload options (bypass cache, clear cookies+reload, etc.). */
GtkWidget *refresh_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(refresh_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(refresh_btn),
gtk_image_new_from_icon_name("view-refresh-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(refresh_btn,
"Reload page (right-click for hard reload options)");
g_signal_connect(refresh_btn, "clicked",
G_CALLBACK(on_refresh_clicked), tab);
g_signal_connect(refresh_btn, "button-press-event",
G_CALLBACK(on_refresh_button_press), tab);
gtk_box_pack_start(GTK_BOX(toolbar), refresh_btn, FALSE, FALSE, 0);
tab->url_entry = gtk_entry_new();
/* Show an empty URL bar for new-tab pages (about:blank) so the user
* can immediately type a URL. For real URLs, show the URL. */
if (default_url && strstr(default_url, "about:blank") != NULL) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
}
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
/* Ensure the webview expands vertically to fill the available space.
* Without this, WebKitGTK may only allocate 1px of height on some
* display servers, resulting in a blank page. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
/* Wire signals. */
g_signal_connect(tab->url_entry, "activate",
G_CALLBACK(on_url_activate), tab);
g_signal_connect(tab->webview, "load-changed",
G_CALLBACK(on_load_changed), tab);
g_signal_connect(tab->webview, "load-failed",
G_CALLBACK(on_load_failed), NULL);
g_signal_connect(tab->webview, "notify::favicon",
G_CALLBACK(on_favicon_changed), tab);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
g_free(default_url);
return tab;
}
/* ── New tab button ───────────────────────────────────────────────── */
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Public API ───────────────────────────────────────────────────── */
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window) {
g_ctx = ctx;
g_window = window;
g_notebook = gtk_notebook_new();
/* Disable scrolling so tabs share the available width evenly instead
* of showing a scrollbar when there are many tabs. */
gtk_notebook_set_scrollable(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE);
/* New-tab button as an action widget at the end of the tab strip. */
GtkWidget *new_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(new_btn),
gtk_image_new_from_icon_name("tab-new-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
g_signal_connect(new_btn, "clicked", G_CALLBACK(on_new_tab_clicked), NULL);
gtk_widget_show_all(new_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn,
GTK_PACK_END);
gtk_container_add(parent, g_notebook);
tab_manager_apply_settings();
}
void tab_manager_apply_settings(void) {
if (g_notebook == NULL) return;
const browser_settings_t *s = settings_get();
gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position);
/* Apply drag reorder to all existing tabs. */
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page) {
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook),
g_tabs[i]->page,
s->tab_drag_reorder);
}
}
}
int tab_manager_new_tab(const char *url) {
const browser_settings_t *s = settings_get();
if (g_tab_count >= s->max_tabs) {
g_print("[tabs] Maximum tab count (%d) reached\n", s->max_tabs);
return -1;
}
tab_info_t *tab = tab_create(url);
if (tab == NULL) return -1;
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
return -1;
}
/* Add to notebook. */
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(g_notebook),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook), tab->page,
s->tab_drag_reorder);
/* Show the tab widgets before switching to it — the page must be
* realized/mapped before it can become the current page and receive
* focus. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* Switch to the new tab (now that it's visible). */
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), page_num);
/* Give the URL entry keyboard focus so the user can immediately
* type a URL. Select all text so typing replaces the current URL.
* Use gtk_widget_grab_focus() on the entry to ensure it receives
* keyboard input. */
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
return page_num;
}
void tab_manager_close_tab(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
/* Remove from notebook (this destroys the page widget). */
gtk_notebook_remove_page(GTK_NOTEBOOK(g_notebook), index);
/* Remove from our array. */
tab_array_remove(index);
g_print("[tabs] Closed tab %d, remaining: %d\n", index, g_tab_count);
/* If no tabs left, quit the app. */
if (g_tab_count == 0) {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
}
}
}
void tab_manager_close_active(void) {
int index = tab_manager_get_active_index();
if (index >= 0) {
tab_manager_close_tab(index);
}
}
int tab_manager_get_active_index(void) {
if (g_notebook == NULL) return -1;
return gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
}
void tab_manager_switch_to(int index) {
if (g_notebook == NULL) return;
if (index < 0 || index >= g_tab_count) return;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), index);
}
tab_info_t *tab_manager_get_active(void) {
int index = tab_manager_get_active_index();
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
tab_info_t *tab_manager_get(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
int tab_manager_count(void) {
return g_tab_count;
}
void tab_manager_set_title(int index, const char *title) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL || tab->title_label == NULL) return;
snprintf(tab->title, sizeof(tab->title), "%s",
(title && title[0]) ? title : "(Untitled)");
gtk_label_set_text(GTK_LABEL(tab->title_label), tab->title);
}
const char *tab_manager_get_url(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return NULL;
return tab->current_url;
}
void tab_manager_next(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int next = (cur + 1) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), next);
}
void tab_manager_prev(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int prev = (cur - 1 + g_tab_count) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), prev);
}
void tab_manager_close_others(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close tabs to the right first (indices shift as we close). */
tab_manager_close_to_right(index);
/* Close tabs to the left (close from the start so indices stay valid). */
while (index > 0) {
tab_manager_close_tab(0);
index--;
}
}
void tab_manager_close_to_right(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close from the end so indices don't shift. */
while (g_tab_count > index + 1) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_close_all(void) {
/* Close from the highest index down to 0 so indices stay valid. */
while (g_tab_count > 0) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_duplicate(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
tab_manager_new_tab(tab->current_url);
}
void tab_manager_reload(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
+149
View File
@@ -0,0 +1,149 @@
/*
* 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);
#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.13"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 3
#define SB_VERSION_PATCH 13
#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))