Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c75c74c38 | ||
|
|
372339aa35 | ||
|
|
c6495beb6d | ||
|
|
3c9be0da01 | ||
|
|
d006d3fa0d | ||
|
|
c6af0179b0 | ||
|
|
fc0e412cc5 | ||
|
|
d64bd45f54 | ||
|
|
5c52198d9e | ||
|
|
0160b70dc2 |
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+210
@@ -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
|
||||
@@ -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 /
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
Executable
+108
@@ -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))
|
||||
Reference in New Issue
Block a user