Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2ad9a6cb | ||
|
|
12054b9900 | ||
|
|
31195c4fe2 | ||
|
|
a4b4c52a91 | ||
|
|
02a92f7a26 | ||
|
|
a722a7cc67 | ||
|
|
d032cef0ab | ||
|
|
775bf27ab0 | ||
|
|
f01a345b7c | ||
|
|
f91ff74138 | ||
|
|
8c75c74c38 | ||
|
|
372339aa35 | ||
|
|
c6495beb6d | ||
|
|
3c9be0da01 | ||
|
|
d006d3fa0d | ||
|
|
c6af0179b0 | ||
|
|
fc0e412cc5 | ||
|
|
d64bd45f54 | ||
|
|
5c52198d9e |
@@ -0,0 +1,72 @@
|
||||
# Agent Instructions — sovereign_browser
|
||||
|
||||
## Browser Management
|
||||
|
||||
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never launch `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open.
|
||||
|
||||
```bash
|
||||
./browser.sh start # build + launch (detached, waits for MCP ready)
|
||||
./browser.sh stop # kill running instance
|
||||
./browser.sh restart # stop + build + start
|
||||
./browser.sh status # check if running + MCP responsive
|
||||
./browser.sh log # tail last 50 lines of browser log
|
||||
```
|
||||
|
||||
The browser's MCP server runs on `http://localhost:17777/mcp` (Streamable HTTP transport).
|
||||
|
||||
## Login
|
||||
|
||||
The browser requires Nostr login before any browser tools work. The quickest way to log in is with a random key:
|
||||
|
||||
```bash
|
||||
# Via MCP (curl):
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"random"}}}'
|
||||
```
|
||||
|
||||
Or use the `login` MCP tool with `{"method": "random"}`. This generates a fresh random Nostr keypair and logs in immediately. The response includes the `nsec` so you can save it if needed.
|
||||
|
||||
Other login methods: `local` (nsec/hex), `seed` (mnemonic), `readonly` (npub), `nip46` (bunker URL), `nsigner` (hardware signer).
|
||||
|
||||
## MCP Server
|
||||
|
||||
The browser exposes 100 MCP tools for browser automation:
|
||||
|
||||
- **Login tools**: `login_status`, `login`, `logout`, `switch_identity`
|
||||
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
|
||||
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`, `eval`, `screenshot`, `screenshot_annotated`
|
||||
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`, `hover`, `focus`, `select`, `check`, `uncheck`, `drag`, `keyboard_type`, `insert_text`, `keydown`, `keyup`, `scroll_into_view`
|
||||
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`, `find_alt`, `find_title`, `find_testid`, `find_first`, `find_last`, `find_nth`
|
||||
- **Get info**: `get_value`, `get_count`, `get_box`, `get_styles`
|
||||
- **Check state**: `is_visible`, `is_enabled`, `is_checked`
|
||||
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
|
||||
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`, `wait_for_fn`
|
||||
- **Batch**: `batch` (execute multiple tool calls in sequence)
|
||||
- **Cookies & storage**: `cookies_get`, `cookies_set`, `cookies_clear`, `storage_local_*`, `storage_session_*`
|
||||
- **Mouse**: `mouse_move`, `mouse_down`, `mouse_up`, `mouse_wheel`
|
||||
- **Clipboard**: `clipboard_read`, `clipboard_write`, `clipboard_copy`, `clipboard_paste`
|
||||
- **Settings**: `set_viewport`, `set_media`, `set_offline`, `set_headers`, `set_credentials`
|
||||
- **Frames**: `frame_switch`, `frame_main`
|
||||
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
|
||||
- **Debug**: `console`, `errors`, `highlight`
|
||||
- **State**: `state_save`, `state_load`
|
||||
- **Files**: `upload`, `pdf`
|
||||
|
||||
## Common Workflow
|
||||
|
||||
1. `./browser.sh start` (or `restart` if already running)
|
||||
2. Login: call `login` tool with `{"method": "random"}`
|
||||
3. Navigate: call `open` tool with a URL
|
||||
4. Snapshot: call `snapshot` to get the accessibility tree with element refs
|
||||
5. Interact: use refs (e.g. `@e1`) with `click`, `fill`, etc.
|
||||
6. Screenshot: call `screenshot` to see the page visually
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make # build the browser
|
||||
make clean # clean build artifacts
|
||||
```
|
||||
|
||||
The browser is a WebKitGTK + C99 application. It links against `webkit2gtk-4.1`, `libsoup-3.0`, and `nostr_core_lib` (vendored).
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"sovereign-browser": {
|
||||
"url": "http://localhost:17777/mcp",
|
||||
"type": "streamable-http",
|
||||
"alwaysAllow": [
|
||||
"login_status",
|
||||
"snapshot",
|
||||
"click",
|
||||
"click_at",
|
||||
"screenshot",
|
||||
"login",
|
||||
"logout",
|
||||
"switch_identity",
|
||||
"open",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"get_url",
|
||||
"get_title",
|
||||
"get_attr",
|
||||
"get_value",
|
||||
"get_count",
|
||||
"get_box",
|
||||
"get_styles",
|
||||
"is_visible",
|
||||
"is_enabled",
|
||||
"is_checked",
|
||||
"eval",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"focus",
|
||||
"close",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"wait",
|
||||
"wait_for",
|
||||
"screenshot_annotated",
|
||||
"pdf",
|
||||
"upload",
|
||||
"state_load",
|
||||
"get_text",
|
||||
"get_html",
|
||||
"scroll",
|
||||
"dblclick",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"scroll_into_view",
|
||||
"insert_text",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"drag",
|
||||
"close_all",
|
||||
"find_role",
|
||||
"find_label",
|
||||
"find_placeholder",
|
||||
"find_alt",
|
||||
"find_title",
|
||||
"find_testid",
|
||||
"find_first",
|
||||
"find_last",
|
||||
"find_nth",
|
||||
"wait_for_text",
|
||||
"wait_for_url",
|
||||
"wait_for_load",
|
||||
"batch",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_local_get",
|
||||
"storage_local_get_key",
|
||||
"storage_local_set",
|
||||
"storage_local_clear",
|
||||
"storage_session_get",
|
||||
"storage_session_get_key",
|
||||
"storage_session_set",
|
||||
"storage_session_clear",
|
||||
"mouse_move",
|
||||
"mouse_down",
|
||||
"mouse_up",
|
||||
"mouse_wheel",
|
||||
"clipboard_read",
|
||||
"clipboard_write",
|
||||
"clipboard_copy",
|
||||
"clipboard_paste",
|
||||
"set_viewport",
|
||||
"set_offline",
|
||||
"set_headers",
|
||||
"set_credentials",
|
||||
"set_media",
|
||||
"frame_switch",
|
||||
"frame_main",
|
||||
"dialog_accept",
|
||||
"dialog_dismiss",
|
||||
"dialog_status",
|
||||
"console",
|
||||
"errors",
|
||||
"highlight",
|
||||
"state_save"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/settings.c src/tab_manager.c src/session.c
|
||||
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c
|
||||
|
||||
$(BIN): $(SRC) $(NOSTR_LIB)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
|
||||
|
||||
@@ -61,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)
|
||||
|
||||
```
|
||||
@@ -163,16 +241,79 @@ 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. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
|
||||
3. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
|
||||
4. ⏳ `window.nostr` injection (`sovereign://` URI scheme bridge)
|
||||
5. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
|
||||
6. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
|
||||
7. ⏳ `nostr://` content scheme
|
||||
8. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
|
||||
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
|
||||
@@ -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,265 @@
|
||||
# Plan: Nostr-Encrypted Bookmarks with Directories
|
||||
|
||||
## Overview
|
||||
|
||||
Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into **directories** (folders). A **bookmark button** in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a `sovereign://bookmarks` page.
|
||||
|
||||
## Event Design
|
||||
|
||||
### Kind 30003 (NIP-51 Bookmark Sets) — one event per directory
|
||||
|
||||
Per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md), **kind 30003** is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a `d` tag = directory name.
|
||||
|
||||
NIP-51 encryption pattern (line 11):
|
||||
> Private items are specified in a JSON array that mimics the structure of the event `tags` array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the `.content`.
|
||||
|
||||
### Event structure (one per directory)
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30003,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [["d", "<directory name>"]],
|
||||
"content": "<NIP-44 encrypted JSON array of bookmarks>",
|
||||
"created_at": <unix timestamp>,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Plaintext format (encrypted in `content`)
|
||||
|
||||
```json
|
||||
[
|
||||
["bookmark", "https://example.com", "Example", 1690000000],
|
||||
["bookmark", "https://nostr.com", "Nostr", 1690000001]
|
||||
]
|
||||
```
|
||||
|
||||
### Directory model
|
||||
|
||||
- Each directory = one kind 30003 event with `d` = directory name.
|
||||
- A default directory `"General"` holds bookmarks that aren't assigned to a specific folder.
|
||||
- The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation).
|
||||
- The list of directory names is derived from the `d` tags of all the user's kind 30003 events — no separate "directory list" event needed.
|
||||
|
||||
### Why kind 30003 (sets) instead of kind 10003 (flat list)?
|
||||
|
||||
| Feature | Kind 10003 | Kind 30003 |
|
||||
|---------|-----------|-----------|
|
||||
| Directories/folders | Not supported (flat list) | **Supported** — each `d` tag is a directory |
|
||||
| Replaceable | Normal replaceable (one event) | Parameterized replaceable (one event per `d` tag) |
|
||||
| NIP-51 standard | Yes — "Bookmarks" | Yes — "Bookmark sets" |
|
||||
| NIP-44 encryption | Documented | Documented (same pattern) |
|
||||
|
||||
Since you want directories, **kind 30003** is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups."
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["User clicks bookmark button"] --> B["Show directory picker dialog"]
|
||||
B --> C{"Select existing or create new?"}
|
||||
C -->|Existing| D["Add bookmark to that directory"]
|
||||
C -->|Create new| E["Enter new directory name"]
|
||||
E --> D
|
||||
D --> F["Update in-memory list for that directory"]
|
||||
F --> G["Serialize directory bookmarks to JSON"]
|
||||
G --> H["NIP-44 encrypt to self"]
|
||||
H --> I["Build kind 30003 event with d=directory"]
|
||||
I --> J["Sign with signer"]
|
||||
J --> K["Publish to bootstrap relays"]
|
||||
J --> L["Store in SQLite db_store_event"]
|
||||
|
||||
M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"]
|
||||
N --> O["Store in SQLite"]
|
||||
O --> P["For each event: decrypt content with NIP-44"]
|
||||
P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"]
|
||||
Q --> R["Populate dropdown menu + bookmarks page"]
|
||||
|
||||
S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"]
|
||||
T --> U["Add/edit/delete/move bookmarks between directories"]
|
||||
U --> V["Save re-encrypt publish affected directory event"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### New files: `src/bookmarks.h` / `src/bookmarks.c`
|
||||
|
||||
```c
|
||||
/* bookmarks.h */
|
||||
#pragma once
|
||||
#include <glib.h>
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#define BOOKMARKS_MAX_PER_DIR 500
|
||||
#define BOOKMARKS_DIR_NAME_MAX 128
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
char *title;
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_t;
|
||||
|
||||
/* Initialize the bookmarks module. Loads cached bookmarks from SQLite
|
||||
* and decrypts them if a signer is available. Call after login. */
|
||||
int bookmarks_init(void);
|
||||
|
||||
/* Free the in-memory bookmark list. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* Get the list of directories (read-only). */
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
|
||||
|
||||
/* Get a specific directory by name (read-only). Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
|
||||
/* Add a bookmark to a directory. If the directory doesn't exist, it's
|
||||
* created. Updates the in-memory list, re-encrypts, publishes a new
|
||||
* kind 30003 event for that directory, and stores in SQLite.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_dir, const char *url,
|
||||
const char *to_dir);
|
||||
|
||||
/* Create a new empty directory. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if directory already exists / error. */
|
||||
int bookmarks_create_dir(const char *name);
|
||||
|
||||
/* Rename a directory. Publishes a new event with the new d-tag and
|
||||
* deletes the old one (kind 5 deletion event).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_rename_dir(const char *old_name, const char *new_name);
|
||||
|
||||
/* Delete a directory. Optionally move bookmarks to "General" first.
|
||||
* Publishes a kind 5 deletion event for the old directory event.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general);
|
||||
|
||||
/* Load bookmarks for a directory from a decrypted kind 30003 event's
|
||||
* content JSON. Called internally by bookmarks_init. */
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
|
||||
|
||||
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */
|
||||
char *bookmarks_dir_to_json(const char *dir_name);
|
||||
|
||||
/* Build and publish the kind 30003 event for a directory.
|
||||
* Encrypts the directory's bookmarks with NIP-44 (self-to-self),
|
||||
* signs, publishes to bootstrap relays, and stores in SQLite.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_publish_dir(const char *dir_name);
|
||||
```
|
||||
|
||||
### Bookmark button in the toolbar
|
||||
|
||||
In `tab_manager.c`'s `tab_create()`, add a bookmark button to the toolbar (right of the URL entry):
|
||||
|
||||
```c
|
||||
/* Bookmark button — right of the URL entry. */
|
||||
GtkWidget *bookmark_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
|
||||
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
|
||||
GTK_ICON_SIZE_MENU));
|
||||
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
|
||||
g_signal_connect(bookmark_btn, "clicked",
|
||||
G_CALLBACK(on_bookmark_clicked), tab);
|
||||
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
|
||||
```
|
||||
|
||||
The `on_bookmark_clicked` handler:
|
||||
1. Gets the active tab's current URL and title.
|
||||
2. Shows a **directory picker dialog** — a small GTK dialog with:
|
||||
- A combo box (dropdown) of existing directories + "New Directory…" option
|
||||
- If "New Directory…" is selected, show a text entry for the new name
|
||||
- An "Add" button and a "Cancel" button
|
||||
3. On "Add", calls `bookmarks_add(dir, url, title)`.
|
||||
4. Shows a brief confirmation (e.g. "Bookmarked to <dir>").
|
||||
|
||||
### sovereign://bookmarks page
|
||||
|
||||
Add a `handle_bookmarks_page` function in `nostr_bridge.c`:
|
||||
|
||||
- Shows directories as a folder list on the left (or as section headings)
|
||||
- Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons
|
||||
- "Add Bookmark" form (URL + title + directory dropdown)
|
||||
- "Create Directory" button
|
||||
- "Rename Directory" / "Delete Directory" buttons per directory
|
||||
- Uses `sovereign://bookmarks/add?dir=...&url=...&title=...`, `sovereign://bookmarks/delete?dir=...&url=...`, `sovereign://bookmarks/move?from=...&to=...&url=...`, `sovereign://bookmarks/createdir?name=...`, `sovereign://bookmarks/renamedir?old=...&new=...`, `sovereign://bookmarks/deletedir?name=...` routes
|
||||
- Styled consistently with settings/profile pages (dark theme, monospace)
|
||||
- In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks"
|
||||
|
||||
### Dropdown menu integration
|
||||
|
||||
In `tab_manager.c`'s `build_hamburger_menu()`, add a "Bookmarks" submenu (like "Recents") that:
|
||||
- Shows directories as sub-submenus (nested GTK menus)
|
||||
- Each directory submenu lists its bookmarks as clickable items
|
||||
- Clicking a bookmark navigates the active tab to that URL
|
||||
- "Manage Bookmarks…" item at the bottom opens `sovereign://bookmarks`
|
||||
- Rebuilds on each show (like `on_history_menu_show`)
|
||||
|
||||
### Relay fetch integration
|
||||
|
||||
Add kind 30003 to the relay fetch filter in `relay_fetch.c`:
|
||||
```c
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* NEW: bookmark sets */
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
The existing `events` + `event_tags` tables store kind 30003 events. The `event_tags` table allows querying by `d` tag to find all directory events for a pubkey. No schema changes needed.
|
||||
|
||||
### Makefile
|
||||
|
||||
Add `src/bookmarks.c` to `SRC`.
|
||||
|
||||
---
|
||||
|
||||
## File change summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/bookmarks.h` | NEW — bookmark/directory data structures + API |
|
||||
| `src/bookmarks.c` | NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management |
|
||||
| `src/nostr_bridge.c` | Add `sovereign://bookmarks` page + action routes (add/delete/move/createdir/renamedir/deletedir) |
|
||||
| `src/tab_manager.c` | Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu |
|
||||
| `src/main.c` | Call `bookmarks_init()` after login, `bookmarks_cleanup()` on shutdown |
|
||||
| `src/relay_fetch.c` | Add kind 30003 to the bootstrap fetch filter |
|
||||
| `Makefile` | Add `src/bookmarks.c` to SRC |
|
||||
|
||||
---
|
||||
|
||||
## Threading and read-only mode
|
||||
|
||||
- **Signing required**: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be *read* from SQLite but not *modified*. The bookmark button and add/delete/move UI are disabled.
|
||||
- **Background publish**: `bookmarks_publish_dir()` calls `synchronous_publish_event_with_progress()` which blocks. Run in a `g_thread_new()` background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async.
|
||||
- **SQLite thread safety**: `SQLITE_OPEN_FULLMUTEX` already set.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
1. **No signer (read-only / no-login)**: bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks."
|
||||
2. **No cached bookmarks**: empty state — "No bookmarks yet. Click the bookmark button to save a page."
|
||||
3. **Decryption failure**: show error, treat that directory as empty.
|
||||
4. **Directory deletion**: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event.
|
||||
5. **Directory rename**: publish new kind 30003 with new `d` tag + kind 5 deletion for old `d` tag.
|
||||
6. **Large directories**: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
|
||||
7. **Conflict resolution**: kind 30003 is parameterized replaceable — latest `created_at` per `d` tag wins. Next relay fetch reconciles.
|
||||
8. **Default directory**: "General" is always present (created on first bookmark if no directories exist).
|
||||
@@ -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,342 @@
|
||||
# Plan: Configurable Keyboard Shortcuts
|
||||
|
||||
## Overview
|
||||
|
||||
Make browser-level keyboard shortcuts user-configurable on the `sovereign://settings` page. The user presses the desired key combination directly in the page (no typing "ctrl-F1" into a text box); the page captures the physical key press, formats it, and persists it. On the C side, the existing hardcoded `on_key_press()` dispatcher in [`main.c`](src/main.c:230) is refactored to look up bindings from a new `shortcuts` module.
|
||||
|
||||
## Nostr sync (NIP-78, kind 30078)
|
||||
|
||||
Per [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md), kind `30078` is an addressable event for **arbitrary custom app data** — its first listed use case is literally *"User personal settings on Nostr clients."* We use it to sync all sovereign_browser user settings (not just shortcuts) across devices.
|
||||
|
||||
### Event design
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [
|
||||
["d", "sovereign_browser"],
|
||||
["client", "sovereign_browser"]
|
||||
],
|
||||
"content": "<NIP-44 encrypted JSON object of all user settings>",
|
||||
"created_at": <unix timestamp>,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **`d` tag**: `"sovereign_browser"` — namespacing so other NIP-78 apps don't collide.
|
||||
- **`content`**: NIP-44 encrypted (to self, same pattern as bookmarks in [`bookmarks.c`](src/bookmarks.c:162)) JSON object containing all user-configurable settings: shortcuts bindings, tab preferences, agent server config, bootstrap relays, security toggles. This keeps potentially sensitive config (relay list, origins) private.
|
||||
- **Plaintext payload** (before encryption):
|
||||
```json
|
||||
{
|
||||
"shortcuts": {
|
||||
"new_tab": "<Control>t",
|
||||
"close_tab": "<Control>w",
|
||||
...
|
||||
},
|
||||
"settings": {
|
||||
"restore_session": true,
|
||||
"new_tab_url": "https://example.com",
|
||||
"tab_bar_position": "top",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sync flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["User changes shortcut in settings UI"] --> B["shortcuts_set action accel"]
|
||||
B --> C["Update in-memory g_parsed"]
|
||||
B --> D["db_kv_set shortcut.X"]
|
||||
D --> E["Debounced publish 500ms"]
|
||||
E --> F["Serialize all settings to JSON"]
|
||||
F --> G["NIP-44 encrypt to self"]
|
||||
G --> H["Build kind 30078 event d=sovereign_browser"]
|
||||
H --> I["Sign with signer"]
|
||||
I --> J["Publish to bootstrap relays"]
|
||||
I --> K["Store in SQLite db_store_event"]
|
||||
|
||||
L["Browser startup / login"] --> M["relay_fetch_thread fetches kind 30078"]
|
||||
M --> N["Store in SQLite"]
|
||||
N --> O["shortcuts_load_from_nostr: decrypt + parse"]
|
||||
O --> P["Merge: Nostr values override local defaults, local overrides win on conflict timestamp"]
|
||||
P --> Q["Populate in-memory g_parsed + write db_kv cache"]
|
||||
```
|
||||
|
||||
### Merge / conflict policy
|
||||
|
||||
On startup, after the relay fetch thread completes (existing [`relay_fetch_thread`](src/relay_fetch.c) already fetches kinds 0/3/10002 — we add 30078), we decrypt the latest kind 30078 `d=sovereign_browser` event and merge:
|
||||
|
||||
1. **Local SQLite `db_kv` is the fast-path cache** — `shortcuts_load()` reads from it for instant startup (no waiting for relays).
|
||||
2. **When the Nostr event arrives**, compare its `created_at` to a stored `settings.nostr_synced_at` timestamp in `db_kv`.
|
||||
3. **If Nostr is newer**: overwrite local `db_kv` from the decrypted payload, reload in-memory bindings, reload the settings page if open.
|
||||
4. **If local is newer** (user changed something offline): keep local, and schedule a re-publish so Nostr catches up.
|
||||
5. **First run / no Nostr event**: defaults are used; nothing published until the user changes something.
|
||||
|
||||
This mirrors how bookmarks work but is simpler (single event vs. one-per-directory).
|
||||
|
||||
### Read-only / no-login mode
|
||||
|
||||
When there's no signer (read-only or `--no-login` mode), NIP-44 encryption isn't available. In that case:
|
||||
- Settings still work fully via local `db_kv` (the fast path).
|
||||
- No kind 30078 event is published or fetched.
|
||||
- The settings page shows a note: *"Nostr sync disabled — log in with a signing key to sync settings across devices."*
|
||||
|
||||
### What goes in the Nostr payload
|
||||
|
||||
| Setting | Synced via NIP-78? | Rationale |
|
||||
|---------|-------------------|-----------|
|
||||
| Shortcut bindings | ✅ Yes | User preference, device-independent |
|
||||
| Tab bar position, close buttons, drag reorder | ✅ Yes | UI preference |
|
||||
| `new_tab_url`, `max_tabs` | ✅ Yes | Preference |
|
||||
| `restore_session` | ❌ No | Device-specific (which tabs were open) |
|
||||
| `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins` | ❌ No | Device-specific (port may differ, origins may be localhost) |
|
||||
| `agent_login_timeout_ms` | ❌ No | Device-specific |
|
||||
| `bootstrap_relays` | ✅ Yes | User's relay list should follow them |
|
||||
| Security toggles (dev_extras, file_access, universal_access) | ❌ No | Device-specific debugging flags |
|
||||
|
||||
The `settings_sync.c` module (see below) knows which keys belong in the Nostr payload via a whitelist.
|
||||
|
||||
## WebKitGTK built-in shortcuts (research finding)
|
||||
|
||||
WebKitGTK webviews consume standard **editing** shortcuts at the webview level *before* they reach our window-level `key-press-event` handler:
|
||||
|
||||
- `Ctrl+C` / `Ctrl+V` / `Ctrl+X` / `Ctrl+A` — clipboard
|
||||
- `Ctrl+Z` / `Ctrl+Y` — undo/redo
|
||||
- `Ctrl+F` — in-page find (only if a `WebKitFindController` is wired up; currently not)
|
||||
- `Tab` / `Shift+Tab` — focus traversal within the page
|
||||
- `F5` / `Ctrl+R` — reload (WebKit handles these for the webview)
|
||||
|
||||
**Browser-level** shortcuts (tab management, URL focus, tab cycling) are **not** handled by WebKit — they are ours, currently hardcoded in [`on_key_press()`](src/main.c:230). These are the ones we make configurable.
|
||||
|
||||
**Conflict policy**: If a user assigns a binding that WebKit consumes for the focused webview (e.g. `Ctrl+C`), the webview eats it and our handler never sees it. The settings UI will warn when a chosen combo is in a "reserved by web content" list, but will still allow it (the user may want it to apply only when the URL bar / tab strip has focus). Recommended defaults stick to `Ctrl+` letter combos and `F1`–`F12` which are safe.
|
||||
|
||||
## Binding format
|
||||
|
||||
Use **GTK accelerator string format** (parseable by `gtk_accelerator_parse`):
|
||||
|
||||
- `<Control>t` — Ctrl+T
|
||||
- `<Control><Shift>Tab` — Ctrl+Shift+Tab
|
||||
- `<Control>F5` — Ctrl+F5
|
||||
- `F1` — bare function key
|
||||
|
||||
This gives us free parsing/serialization via `gtk_accelerator_parse()` / `gtk_accelerator_name()` and keeps the stored representation compact and human-readable.
|
||||
|
||||
## Action registry
|
||||
|
||||
A fixed enum of browser actions the user can bind. Each has: id, display label, description, default binding.
|
||||
|
||||
| Action ID | Label | Default binding |
|
||||
|----------------------|------------------------|---------------------------|
|
||||
| `new_tab` | New tab | `<Control>t` |
|
||||
| `close_tab` | Close tab | `<Control>w` |
|
||||
| `focus_url` | Focus URL bar | `<Control>l` |
|
||||
| `next_tab` | Next tab | `<Control>Tab` |
|
||||
| `prev_tab` | Previous tab | `<Control><Shift>Tab` |
|
||||
| `next_tab_pagedown` | Next tab (PageDown) | `<Control>Page_Down` |
|
||||
| `prev_tab_pageup` | Previous tab (PageUp) | `<Control>Page_Up` |
|
||||
| `reload` | Reload page | `F5` |
|
||||
| `force_reload` | Force reload | `<Shift>F5` |
|
||||
| `go_back` | Go back | `<Alt>Left` |
|
||||
| `go_forward` | Go forward | `<Alt>Right` |
|
||||
| `find` | Find in page | `<Control>f` |
|
||||
| `open_settings` | Open settings page | `<Control>comma` |
|
||||
| `new_identity` | Switch identity | `<Control><Shift>i` |
|
||||
| `toggle_fullscreen` | Toggle fullscreen | `F11` |
|
||||
|
||||
(Exact list is adjustable; the registry is a single table in `shortcuts.c` so adding/removing actions is trivial.)
|
||||
|
||||
## Storage
|
||||
|
||||
Each binding is one row in the existing `key_value` table:
|
||||
|
||||
- key: `shortcut.<action_id>` (e.g. `shortcut.new_tab`)
|
||||
- value: accelerator string (e.g. `<Control>t`)
|
||||
|
||||
Loaded once at startup into an in-memory array indexed by action enum. `settings_save()` is not extended — the shortcuts module owns its own load/save via `db_kv_get`/`db_kv_set`.
|
||||
|
||||
## New module: `shortcuts.h` / `shortcuts.c`
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["db_kv_get shortcut.*"] --> B["shortcuts_load"]
|
||||
B --> C["g_bindings array"]
|
||||
D["on_key_press event"] --> E["shortcuts_lookup event"]
|
||||
C --> E
|
||||
E --> F["action enum or -1"]
|
||||
F --> G["dispatch switch in main.c"]
|
||||
H["sovereign://settings/set?key=shortcut.X"] --> I["shortcuts_set action accel"]
|
||||
I --> J["db_kv_set + update g_bindings"]
|
||||
```
|
||||
|
||||
### `shortcuts.h` (public API)
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
SHORTCUT_PREV_TAB,
|
||||
SHORTCUT_NEXT_TAB_PAGEDOWN,
|
||||
SHORTCUT_PREV_TAB_PAGEUP,
|
||||
SHORTCUT_RELOAD,
|
||||
SHORTCUT_FORCE_RELOAD,
|
||||
SHORTCUT_GO_BACK,
|
||||
SHORTCUT_GO_FORWARD,
|
||||
SHORTCUT_FIND,
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
/* Load all bindings from db_kv_get into the in-memory array.
|
||||
* Call once at startup after db_init(). Missing keys use defaults. */
|
||||
void shortcuts_load(void);
|
||||
|
||||
/* Look up which action a key event matches, or -1 if none.
|
||||
* Parses the stored accelerator strings once (cached) and compares
|
||||
* keyval + mods. */
|
||||
int shortcuts_lookup(GdkEventKey *event);
|
||||
|
||||
/* Get the accelerator string for an action (e.g. for the settings UI). */
|
||||
const char *shortcuts_get(shortcut_action_t action);
|
||||
|
||||
/* Set and persist a new binding for an action.
|
||||
* accel_str must be parseable by gtk_accelerator_parse.
|
||||
* Returns 0 on success, -1 on parse failure. */
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str);
|
||||
|
||||
/* Reset an action to its default binding (and persist). */
|
||||
void shortcuts_reset(shortcut_action_t action);
|
||||
|
||||
/* Reset all actions to defaults (and persist). */
|
||||
void shortcuts_reset_all(void);
|
||||
|
||||
/* Metadata for the settings UI: label, description, default accel. */
|
||||
typedef struct {
|
||||
const char *id; /* "new_tab" */
|
||||
const char *label; /* "New tab" */
|
||||
const char *desc; /* "Open a new tab" */
|
||||
const char *dflt; /* "<Control>t" */
|
||||
} shortcut_meta_t;
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
|
||||
```
|
||||
|
||||
### `shortcuts.c` (implementation notes)
|
||||
|
||||
- Static array `static struct { guint keyval; GdkModifierType mods; } g_parsed[SHORTCUT_COUNT];` populated by `gtk_accelerator_parse()` on load and on each `shortcuts_set()`.
|
||||
- `shortcuts_lookup()` applies `gtk_accelerator_get_default_mod_mask()` to the event state, then linear-scans `g_parsed` comparing `keyval` and masked `mods`. Returns first match (actions are distinct enough that collisions are the user's problem; the UI warns on duplicates).
|
||||
- `shortcuts_set()` calls `gtk_accelerator_parse(accel_str, &key, &mods)`; returns -1 if parse fails. On success, updates `g_parsed`, writes `db_kv_set("shortcut.<id>", accel_str)`.
|
||||
|
||||
## Refactor `on_key_press()` in `main.c`
|
||||
|
||||
Replace the hardcoded if/else chain with:
|
||||
|
||||
```c
|
||||
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) {
|
||||
int action = shortcuts_lookup(event);
|
||||
if (action < 0) return FALSE;
|
||||
|
||||
switch ((shortcut_action_t)action) {
|
||||
case SHORTCUT_NEW_TAB: tab_manager_new_tab(NULL); return TRUE;
|
||||
case SHORTCUT_CLOSE_TAB: tab_manager_close_active(); return TRUE;
|
||||
case SHORTCUT_FOCUS_URL: /* ... existing Ctrl+L logic ... */ return TRUE;
|
||||
case SHORTCUT_NEXT_TAB: tab_manager_next(); return TRUE;
|
||||
case SHORTCUT_PREV_TAB: tab_manager_prev(); return TRUE;
|
||||
/* ... etc ... */
|
||||
case SHORTCUT_FIND: /* open find bar (new) */ return TRUE;
|
||||
case SHORTCUT_OPEN_SETTINGS: on_menu_settings(NULL, NULL); return TRUE;
|
||||
/* ... */
|
||||
default: return FALSE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `ctrl_tab_switch` boolean setting is removed from the settings struct (or kept as a quick on/off for the whole shortcuts feature). Decision: **keep `ctrl_tab_switch` as a master "enable keyboard shortcuts" toggle** — when off, `shortcuts_lookup()` returns -1 immediately. This preserves the existing one-click disable behavior.
|
||||
|
||||
## Settings page UI — "Keyboard Shortcuts" section
|
||||
|
||||
Added to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML, between "Tabs" and "Agent Server". For each action in the registry, render a row:
|
||||
|
||||
```
|
||||
[ New tab ] [ <Control>t ] [ Change ] [ Reset ]
|
||||
[ Close tab ] [ <Control>w ] [ Change ] [ Reset ]
|
||||
...
|
||||
[ Reset all to defaults ]
|
||||
```
|
||||
|
||||
### Key-capture interaction (the "press the keys" UX)
|
||||
|
||||
Each row's **Change** button focuses a hidden capture `<input>` and shows "Press keys…". The input has a `keydown` listener that:
|
||||
|
||||
1. Calls `event.preventDefault()` and `event.stopPropagation()` so the key combo is never delivered to the page.
|
||||
2. Reads `event.ctrlKey`, `event.altKey`, `event.shiftKey`, `event.metaKey` and `event.key` / `event.code`.
|
||||
3. Maps the JS key to a GTK accelerator string. Mapping rules:
|
||||
- Modifier prefix: `<Control>` / `<Alt>` / `<Shift>` / `<Super>` in that order.
|
||||
- Key normalization: letters → lowercase; `F1`–`F12` as-is; special keys (`Tab`, `Page_Down`, `Page_Up`, `Left`, `Right`, `comma`, `space`) mapped to GDK key names.
|
||||
- Bare modifier-only presses are ignored (wait for a real key).
|
||||
4. Displays the formatted string in the row (e.g. `Ctrl+Shift+T` for display, but sends `<Control><Shift>t` to the backend).
|
||||
5. On `Enter` or blur, commits via `fetch('sovereign://settings/set?key=shortcut.new_tab&value=' + encodeURIComponent(accel))`.
|
||||
6. `Escape` cancels capture without committing.
|
||||
|
||||
A small JS lookup table maps JS `event.code`/`event.key` → GDK key name for the ~20 keys we care about. This keeps the capture logic in the page without needing a round-trip to C for each keypress.
|
||||
|
||||
### Backend handling in `handle_settings_set()`
|
||||
|
||||
Add a branch in the key/value path of [`handle_settings_set()`](src/nostr_bridge.c:705):
|
||||
|
||||
```c
|
||||
if (strncmp(key, "shortcut.", 9) == 0) {
|
||||
const char *action_id = key + 9;
|
||||
/* find action by id string in the registry */
|
||||
shortcut_action_t a = shortcuts_action_from_id(action_id);
|
||||
if (a < 0) { respond_error_json(...); return; }
|
||||
if (shortcuts_set(a, value) != 0) {
|
||||
respond_error_json(request, -1, "Invalid key combination");
|
||||
return;
|
||||
}
|
||||
/* respond with ok + reload */
|
||||
}
|
||||
```
|
||||
|
||||
Also add a `shortcut.reset` and `shortcut.reset_all` pseudo-keys (or a separate `sovereign://settings/shortcut-reset?action=X` endpoint) for the Reset buttons.
|
||||
|
||||
### Conflict / duplicate warnings
|
||||
|
||||
The settings page JS, after a successful commit, can fetch all current bindings (embedded in the page HTML on render) and highlight any other action that now shares the same accelerator string. The backend `shortcuts_set()` does **not** reject duplicates — last-wins at lookup time — but the UI flags them in red so the user notices.
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/shortcuts.h` | **New** — action enum + public API |
|
||||
| `src/shortcuts.c` | **New** — registry, load/save (from `db_kv`), lookup, parse |
|
||||
| `src/settings_sync.h` | **New** — NIP-78 sync API: `settings_sync_init()`, `settings_sync_publish()`, `settings_sync_merge_from_nostr()` |
|
||||
| `src/settings_sync.c` | **New** — serialize whitelisted settings + shortcuts to JSON, NIP-44 encrypt to self, build/sign kind 30078 `d=sovereign_browser`, publish to relays, store in SQLite; decrypt + merge on fetch. Reuses the pattern from [`bookmarks.c`](src/bookmarks.c:214) `publish_directory()`. |
|
||||
| `src/main.c` | Refactor [`on_key_press()`](src/main.c:230) to dispatch via `shortcuts_lookup()`; call `shortcuts_load()` after `settings_load()`; call `settings_sync_init()` after login; add action handlers (find, settings, fullscreen, back/forward) |
|
||||
| `src/nostr_bridge.c` | Add "Keyboard Shortcuts" section to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML + key-capture JS; add `shortcut.*` branch to [`handle_settings_set()`](src/nostr_bridge.c:705); trigger debounced `settings_sync_publish()` on any settings change |
|
||||
| `src/settings.h` / `settings.c` | Repurpose `ctrl_tab_switch` → `shortcuts_enabled` master toggle (or keep name + add alias); update defaults + load/save |
|
||||
| `src/relay_fetch.c` / `relay_fetch.h` | Add kind 30078 to the fetched kinds; after fetch, call `settings_sync_merge_from_nostr()` to decrypt and merge |
|
||||
| `Makefile` | Add `src/shortcuts.c` and `src/settings_sync.c` to `SRC` |
|
||||
| `src/tab_manager.h` / `tab_manager.c` | Add `tab_manager_find()` / find-bar helpers if `SHORTCUT_FIND` is implemented in this phase (can be stubbed for later) |
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1a (local):** shortcuts module + refactor `on_key_press` + settings UI with key capture. Covers all currently-hardcoded actions plus `reload`, `go_back`, `go_forward`, `open_settings`, `toggle_fullscreen` (these already have tab_manager/menu functions to call). Persistence via `db_kv` only.
|
||||
|
||||
**Phase 1b (Nostr sync):** `settings_sync` module — publish kind 30078 on debounced settings changes, fetch + merge on startup. This makes shortcuts (and other whitelisted settings) sync across devices.
|
||||
|
||||
**Phase 2 (future):** `find` (requires wiring `WebKitFindController` + a find bar widget), `new_identity` (calls `app_menu_switch_identity_proxy`), `force_reload` (bypass cache).
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual: open `sovereign://settings`, press Change on "New tab", press `Ctrl+Q`, verify it persists and `Ctrl+Q` now opens a new tab on next key press (same session — `shortcuts_set` updates the in-memory array immediately).
|
||||
- Restart browser, verify binding persisted in SQLite.
|
||||
- Reset single + reset all buttons restore defaults.
|
||||
- Duplicate-binding warning appears when two actions share a combo.
|
||||
- Bare modifier press (just Ctrl) doesn't commit; Escape cancels.
|
||||
- `shortcuts_enabled = false` disables all lookup.
|
||||
@@ -0,0 +1,78 @@
|
||||
# MCP Server Improvements Plan
|
||||
|
||||
## Problem Assessment
|
||||
|
||||
The sovereign_browser MCP server uses JavaScript-level interaction (JS `element.click()`,
|
||||
`dispatchEvent`, `document.querySelector`) which has critical limitations:
|
||||
|
||||
1. **CSS selectors with special chars** (e.g. `[60vh]`) break in JSON escaping
|
||||
2. **JS `.click()` doesn't trigger SPA framework handlers** (React synthetic events, Radix UI pointer events)
|
||||
3. **Stale refs fail completely** instead of re-resolving
|
||||
4. **No coordinate-based interaction** — everything depends on CSS selectors working
|
||||
|
||||
## agent-browser's Approach (CDP)
|
||||
|
||||
agent-browser uses Chrome DevTools Protocol:
|
||||
- `Accessibility.getFullAXTree` → native AX tree with `backend_node_id` per node
|
||||
- `DOM.getBoxModel(backend_node_id)` → pixel coordinates of element
|
||||
- `Input.dispatchMouseEvent(x, y)` → real browser-level mouse events
|
||||
- Stale `backend_node_id` → re-query AX tree by role+name
|
||||
|
||||
## Proposed Improvements for sovereign_browser
|
||||
|
||||
### 1. Coordinate-based clicking (highest impact, easiest)
|
||||
|
||||
Instead of JS `.click()`, get the element's bounding box and synthesize a GDK event:
|
||||
|
||||
```c
|
||||
// In agent_tools.c, the click tool:
|
||||
// 1. JS: getBoundingClientRect() → {x, y, width, height}
|
||||
// 2. C: gdk_event_new(GDK_BUTTON_PRESS) at center coordinates
|
||||
// 3. C: gtk_main_do_event() to dispatch to WebKit
|
||||
```
|
||||
|
||||
This triggers real WebKit hit-testing and full event propagation, matching how
|
||||
agent-browser's `Input.dispatchMouseEvent` works.
|
||||
|
||||
### 2. Fix ref storage — store bounding box, not just CSS selector
|
||||
|
||||
At snapshot time, store each ref's:
|
||||
- `backend_node_id` (WebKit accessible's unique ID)
|
||||
- `role` + `name` (for re-resolution if stale)
|
||||
- `bounding_box` (x, y, width, height — for coordinate-based fallback)
|
||||
- `selector` (CSS selector — as secondary fallback)
|
||||
|
||||
### 3. Stale ref fallback — re-resolve by role+name
|
||||
|
||||
If the CSS selector fails, re-query the accessibility tree by role+name
|
||||
(matching agent-browser's `find_node_id_by_role_name` approach).
|
||||
|
||||
### 4. Use WebKit's native accessibility tree for snapshots
|
||||
|
||||
Instead of walking the DOM in JS, use WebKit's ATK accessibility:
|
||||
```c
|
||||
WebKitWebView *wv = ...;
|
||||
AtkObject *root = webkit_web_view_get_accessible(wv);
|
||||
// Walk the ATK tree — stable node IDs, no CSS selector issues
|
||||
```
|
||||
|
||||
### 5. Add `click_at(x, y)` tool
|
||||
|
||||
A simple coordinate-based click tool that synthesizes GDK events.
|
||||
Useful when you know the coordinates from a screenshot.
|
||||
|
||||
### 6. Fix `fill`/`type` to use GDK keyboard events
|
||||
|
||||
Instead of JS `element.value = ...`, use:
|
||||
- JS to focus the element
|
||||
- GDK key events to type character by character
|
||||
- This triggers proper input validation and React onChange handlers
|
||||
|
||||
## Priority
|
||||
|
||||
1. **Coordinate-based clicking** — fixes the immediate "can't click buttons" problem
|
||||
2. **Fix ref storage with bounding box** — enables coordinate fallback
|
||||
3. **Stale ref fallback** — improves reliability
|
||||
4. **Native AX tree snapshots** — better stability, less JS dependency
|
||||
5. **GDK keyboard events for typing** — better SPA compatibility
|
||||
6. **`click_at` tool** — convenience for screenshot-based interaction
|
||||
@@ -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,121 @@
|
||||
# Per-User Profile Directory System
|
||||
|
||||
## Problem
|
||||
|
||||
When logging in as a new user (different nsec), the browser:
|
||||
1. Restores the previous user's tabs (session restore from global SQLite)
|
||||
2. Shows the previous user's Nostr events on the profile page
|
||||
3. Shows the previous user's bookmarks, history, and recents
|
||||
|
||||
## Solution
|
||||
|
||||
Create a per-user profile directory keyed by npub, with each user getting their own SQLite database, session, bookmarks, and history.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
~/.sovereign_browser/
|
||||
├── identity.json # last used npub (for login dialog default)
|
||||
├── global.db # global settings (agent server, theme, shortcuts, inspector)
|
||||
└── profiles/
|
||||
├── npub1abc.../
|
||||
│ ├── browser.db # per-user: events, history, session, bookmarks, key_value
|
||||
│ └── identity.json # this user's nsec/npub
|
||||
├── npub1def.../
|
||||
│ ├── browser.db
|
||||
│ └── identity.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
## What Stays Global vs Per-User
|
||||
|
||||
### Global (in `~/.sovereign_browser/global.db`)
|
||||
- `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins`, `agent_login_timeout_ms`
|
||||
- `theme_dark`
|
||||
- `shortcut.*` (keyboard shortcuts)
|
||||
- `inspector_x/y/w/h`
|
||||
- `dev_extras`, `file_access`, `universal_access` (security settings — these are WebKit-level, not per-user)
|
||||
|
||||
### Per-User (in `~/.sovereign_browser/profiles/<npub>/browser.db`)
|
||||
- `restore_session`, `new_tab_url`, `tab_bar_position`, `show_tab_close_buttons`, `middle_click_close`, `ctrl_tab_switch`, `max_tabs`, `tab_drag_reorder`
|
||||
- `bootstrap_relays`, `search_engine`
|
||||
- Session (open tabs)
|
||||
- History
|
||||
- Bookmarks
|
||||
- Nostr events (kind 0, 3, 10002)
|
||||
- key_value table (per-user settings)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add `db_init_with_path(const char *path)` to db.c
|
||||
|
||||
Currently `db_init()` hardcodes `~/.sovereign_browser/browser.db`. Add a variant that takes a path. Keep `db_init()` for the global database.
|
||||
|
||||
### Step 2: Add profile directory management
|
||||
|
||||
New functions in a `profile.c` module (or add to `key_store.c`):
|
||||
- `profile_get_dir(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/`
|
||||
- `profile_ensure_dir(const char *npub)` — creates the directory if it doesn't exist
|
||||
- `profile_get_db_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/browser.db`
|
||||
- `profile_get_identity_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/identity.json`
|
||||
|
||||
### Step 3: Split settings into global and per-user
|
||||
|
||||
In `settings.c`:
|
||||
- `settings_load_global()` — loads from global.db (agent server, theme, shortcuts, inspector, security)
|
||||
- `settings_load_user()` — loads from the per-user browser.db (session, tabs, relays, search)
|
||||
- `settings_save_global()` — saves global settings to global.db
|
||||
- `settings_save_user()` — saves per-user settings to browser.db
|
||||
|
||||
Or simpler: keep one `settings_load()` / `settings_save()` but have it read from the appropriate database based on which keys are global vs per-user.
|
||||
|
||||
### Step 4: Change the login flow in main.c
|
||||
|
||||
Current flow:
|
||||
1. `db_init()` — opens global browser.db
|
||||
2. `settings_load()` — loads all settings from browser.db
|
||||
3. `shortcuts_load()` — loads shortcuts from browser.db
|
||||
4. Login (GTK dialog or agent login or CLI)
|
||||
5. `relay_fetch()` — fetches events from relays
|
||||
6. `session_restore()` — restores tabs from browser.db
|
||||
|
||||
New flow:
|
||||
1. `db_init_global()` — opens global.db for global settings
|
||||
2. `settings_load_global()` — loads global settings (agent server, theme, shortcuts, inspector)
|
||||
3. `shortcuts_load()` — loads shortcuts from global.db
|
||||
4. Login — get npub
|
||||
5. `profile_ensure_dir(npub)` — create `~/.sovereign_browser/profiles/<npub>/`
|
||||
6. `db_init_user(npub)` — close global.db, open `profiles/<npub>/browser.db`
|
||||
7. `settings_load_user()` — load per-user settings from browser.db
|
||||
8. `relay_fetch()` — fetch events from relays into per-user browser.db
|
||||
9. `session_restore()` — restore tabs from per-user browser.db
|
||||
|
||||
### Step 5: Migration
|
||||
|
||||
On first run with the new system:
|
||||
- If `~/.sovereign_browser/browser.db` exists and `~/.sovereign_browser/profiles/` doesn't:
|
||||
- After login, copy `browser.db` to `profiles/<npub>/browser.db`
|
||||
- Move global settings from browser.db to global.db
|
||||
- Keep browser.db as backup (or rename to browser.db.old)
|
||||
|
||||
### Step 6: Identity persistence
|
||||
|
||||
- `~/.sovereign_browser/identity.json` — stores the last-used npub (not nsec)
|
||||
- `~/.sovereign_browser/profiles/<npub>/identity.json` — stores the nsec/npub for that profile
|
||||
- On startup, read the last-used npub from global identity.json to pre-fill the login dialog
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/db.c` / `src/db.h` — add `db_init_with_path()`, `db_close()`, `db_init_global()`
|
||||
2. `src/settings.c` / `src/settings.h` — split into global/per-user load/save
|
||||
3. `src/main.c` — change the startup flow
|
||||
4. `src/key_store.c` — update identity persistence to per-profile
|
||||
5. `src/session.c` — no change (already uses db_session_* which will use the per-user db)
|
||||
6. `src/shortcuts.c` — load from global.db instead of browser.db
|
||||
7. New: `src/profile.c` / `src/profile.h` — profile directory management
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Medium risk** — changes the database initialization flow which affects all data access
|
||||
- **Migration needed** — existing users need their data moved to per-profile dirs
|
||||
- **Testing needed** — login with different users, verify data isolation
|
||||
@@ -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,127 @@
|
||||
# Search Engine Integration & URL Bar Dropdown
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate a search engine (DuckDuckGo default, user-changeable) into the URL bar. When the user types, a dropdown appears with **direct links first** (from history, bookmarks, and domain heuristics), followed by **search engine suggestions**. Selecting a direct link navigates to that URL; selecting a search suggestion goes to the search engine results page.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User types in URL entry] --> B[on_url_changed callback]
|
||||
B --> C[Clear suggestion list store]
|
||||
C --> D[db_history_search query]
|
||||
C --> E[bookmarks_search query]
|
||||
C --> F[domain heuristic]
|
||||
D --> G[Add direct-link rows to model]
|
||||
E --> G
|
||||
F --> G
|
||||
B --> H[Async SoupRequest to DDG ac API]
|
||||
H --> I[Add search-suggestion rows to model]
|
||||
G --> J[GtkEntryCompletion dropdown shows]
|
||||
I --> J
|
||||
J --> K{User selects a row}
|
||||
K -->|direct link| L[webkit_web_view_load_uri url]
|
||||
K -->|search suggestion| M[load search engine URL with query]
|
||||
K -->|Enter, no selection| N{Is input a URL?}
|
||||
N -->|Yes| L
|
||||
N -->|No| M
|
||||
```
|
||||
|
||||
## Data Sources for Direct Links
|
||||
|
||||
1. **History** — SQLite `history` table, searched by URL/title substring, ranked by `visit_count DESC, visited_at DESC`. These are sites the user has actually visited.
|
||||
2. **Bookmarks** — In-memory bookmark list, searched by URL/title substring.
|
||||
3. **Domain heuristic** — If the typed text has no spaces and no dots, offer `https://<text>.org` and `https://<text>.com` as "Navigate directly" options. Handles first-time visits to known domains.
|
||||
|
||||
## Search Engine Suggestions
|
||||
|
||||
DuckDuckGo autocomplete API: `https://duckduckgo.com/ac/?q=<query>&type=list`
|
||||
Returns a JSON array: `["wikipedia", "wikipedia english", ...]`
|
||||
Fetched asynchronously via libsoup (already linked). Results populate the lower section of the dropdown. Selecting one navigates to `https://duckduckgo.com/?q=<term>`.
|
||||
|
||||
## Search Engine Configuration
|
||||
|
||||
Built-in engines hardcoded in `search.c`:
|
||||
|
||||
| Engine | Search URL | Suggestion URL |
|
||||
|--------|-----------|----------------|
|
||||
| DuckDuckGo | `https://duckduckgo.com/?q=%s` | `https://duckduckgo.com/ac/?q=%s&type=list` |
|
||||
| Google | `https://www.google.com/search?q=%s` | `https://suggestqueries.google.com/complete/search?client=firefox&q=%s` |
|
||||
| Brave | `https://search.brave.com/search?q=%s` | `https://search.brave.com/api/suggest?q=%s` |
|
||||
| Startpage | `https://www.startpage.com/sp/search?query=%s` | (none — no autocomplete) |
|
||||
| Searx | `https://searx.be/search?q=%s` | (none) |
|
||||
|
||||
The selected engine name is stored in settings (`search_engine` key) and synced via NIP-78.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `src/search.h` + `src/search.c`
|
||||
- `search_engine_t` struct: name, search_url_template, suggestion_url_template
|
||||
- `search_engines_get()` — returns array of built-in engines
|
||||
- `search_engine_get_active()` — returns the currently selected engine
|
||||
- `search_engine_build_search_url(const char *query)` — builds a search URL from the active engine's template
|
||||
- `search_engine_build_suggestion_url(const char *query)` — builds the suggestion API URL
|
||||
- `search_suggest_fetch_async(const char *query, GFunc callback, gpointer user_data)` — async libsoup HTTP GET, parses JSON array, calls callback with results
|
||||
- `search_is_url(const char *input)` — heuristic: returns TRUE if input looks like a URL (has scheme, or has a dot with no spaces)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `src/settings.h` + `src/settings.c`
|
||||
- Add `char search_engine[64]` to `browser_settings_t` (default "duckduckgo")
|
||||
- Load/save the `search_engine` key from db_kv
|
||||
|
||||
### `src/db.h` + `src/db.c`
|
||||
- Add `db_history_search(const char *query, char ***urls_out, char ***titles_out, int *count_out, int limit)` — `WHERE url LIKE '%q%' OR title LIKE '%q%' ORDER BY visit_count DESC, visited_at DESC LIMIT ?`
|
||||
- Add index on `history(url)` and `history(title)` for search performance
|
||||
|
||||
### `src/tab_manager.c`
|
||||
- Add a `GtkListStore` (per-tab or shared) with columns: display text, URL, item type (direct/suggestion), icon name
|
||||
- Add `GtkEntryCompletion` to each tab's `url_entry` with a custom match function
|
||||
- Add `on_url_changed` callback — rebuilds the list store on each keystroke:
|
||||
1. Clear store
|
||||
2. Query history via `db_history_search()`
|
||||
3. Query bookmarks via `bookmarks_get_dirs()` + filter
|
||||
4. Add domain heuristic entries if applicable
|
||||
5. Fire async `search_suggest_fetch_async()` — on completion, append suggestion rows
|
||||
- Override `match-selected` signal — read the URL column; if it's a direct link, navigate; if it's a search suggestion, build search URL and navigate
|
||||
- Modify `on_url_activate` — if input is not a URL (per `search_is_url()`), build a search URL and navigate instead of prepending `https://`
|
||||
- Modify `normalize_url` — if input is not a URL and not an about: URL, treat as a search query
|
||||
|
||||
### `src/nostr_bridge.c`
|
||||
- Add a "Search Engine" section to the settings page HTML with a `<select>` dropdown of built-in engines
|
||||
- Add `search_engine` to the `handle_settings_set` key/value handler
|
||||
|
||||
### `src/settings_sync.c`
|
||||
- Add `"search_engine"` to `g_sync_setting_keys[]` whitelist
|
||||
|
||||
### `Makefile`
|
||||
- Add `src/search.c` to `SRC`
|
||||
|
||||
## Dropdown UX Design
|
||||
|
||||
Each row in the completion dropdown shows:
|
||||
- **Direct links** (history/bookmarks/domain): `🌐 wikipedia.org — Wikipedia` (URL + title), with a history/bookmark icon
|
||||
- **Search suggestions**: `🔍 wikipedia english` (search term), with a search icon
|
||||
|
||||
Direct links always appear before search suggestions. A visual separator (different icon, possibly different text color via Pango markup) distinguishes the two categories.
|
||||
|
||||
## Enter Key Behavior
|
||||
|
||||
When the user presses Enter without selecting a dropdown item:
|
||||
1. If `search_is_url(input)` is true → navigate to the URL (current behavior)
|
||||
2. If not → build a search URL from the active engine and navigate
|
||||
|
||||
This means typing "wikipedia" and pressing Enter goes to DDG search, but typing "wikipedia.org" and pressing Enter goes directly to wikipedia.org. Typing "wikipedia" and selecting the history entry for wikipedia.org from the dropdown goes directly there.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `src/search.h` + `src/search.c` (engine definitions, URL builders, async suggestion fetch, URL heuristic)
|
||||
2. Add `db_history_search()` to `src/db.h` + `src/db.c` + search indexes
|
||||
3. Add `search_engine` setting to `src/settings.h` + `src/settings.c`
|
||||
4. Add search engine dropdown to settings page in `src/nostr_bridge.c`
|
||||
5. Add `search_engine` to sync whitelist in `src/settings_sync.c`
|
||||
6. Implement URL bar dropdown in `src/tab_manager.c` (list store, entry completion, changed callback, match-selected handler)
|
||||
7. Update `on_url_activate` / `normalize_url` for search fallback
|
||||
8. Add `src/search.c` to `Makefile`
|
||||
9. Build and test
|
||||
@@ -0,0 +1,167 @@
|
||||
# Plan: Migrate All Storage to SQLite + Remove Key Persistence
|
||||
|
||||
## Overview
|
||||
|
||||
Consolidate all browser data storage into the SQLite database (`~/.sovereign_browser/browser.db`) and **remove all key persistence** — private keys live only in RAM and are gone when the browser quits.
|
||||
|
||||
## Current state
|
||||
|
||||
| File | Format | Stores | Action |
|
||||
|------|--------|--------|--------|
|
||||
| `identity.json` | JSON | Nostr private key, mnemonic, bunker URL | **Delete the code + file** — keys must never touch disk |
|
||||
| `history.txt` | Plain text | Recent URLs (max 50) | **Migrate to SQLite** `history` table |
|
||||
| `session.txt` | Plain text | Open tab URLs | **Migrate to SQLite** `session` table |
|
||||
| `settings.conf` | key=value | Browser preferences | **Migrate to SQLite** `key_value` table (already exists) |
|
||||
| `browser.db` | SQLite | Nostr events, tags, key_value | **Keep + extend** |
|
||||
|
||||
## Phase 1: Remove key persistence
|
||||
|
||||
### `src/key_store.h` / `src/key_store.c`
|
||||
|
||||
**Remove:**
|
||||
- `key_store_path()` — no file path needed
|
||||
- `key_store_save()` — never called (dead code)
|
||||
- `key_store_load()` — never called (dead code)
|
||||
- `key_store_clear()` — replace with a no-op or remove the calls
|
||||
|
||||
**Keep:**
|
||||
- `key_store_identity_t` struct (in-memory only)
|
||||
- `key_store_method_t` enum
|
||||
- `key_store_create_signer()` — creates a signer from an in-memory identity
|
||||
|
||||
### `src/cli.h` / `src/cli.c`
|
||||
|
||||
**Remove:**
|
||||
- `--no-save-identity` flag and `no_save_identity` field (meaningless now)
|
||||
- The TODO comment about `key_store_save()`
|
||||
|
||||
### `src/main.c` / `src/agent_login.c`
|
||||
|
||||
**Remove:**
|
||||
- `key_store_clear()` calls in `app_menu_logout_proxy()` and `agent_login.c` logout
|
||||
|
||||
### Defensive cleanup
|
||||
|
||||
On startup, delete `~/.sovereign_browser/identity.json` if it exists (in case a previous version created it).
|
||||
|
||||
## Phase 2: Migrate history to SQLite
|
||||
|
||||
### `src/db.h` / `src/db.c`
|
||||
|
||||
Add a `history` table to the schema:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
visited_at INTEGER NOT NULL,
|
||||
visit_count INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_visited_at ON history(visited_at DESC);
|
||||
```
|
||||
|
||||
Add functions:
|
||||
```c
|
||||
int db_history_add(const char *url, const char *title);
|
||||
/* Returns most-recent-first. Fills urls_out (caller frees each + array). */
|
||||
char **db_history_get(int *count_out, int limit);
|
||||
int db_history_clear(void);
|
||||
```
|
||||
|
||||
`db_history_add` does an UPSERT: on conflict (URL already exists), increment `visit_count` and update `visited_at`.
|
||||
|
||||
### `src/history.h` / `src/history.c`
|
||||
|
||||
Rewrite to use `db_history_add` / `db_history_get` / `db_history_clear`. Remove the flat-file `history_path()`, `fopen()`, the `g_history[50][2048]` array, and `history_load()`. The `history_add()` function now takes an optional title parameter (or we keep the existing signature and pass NULL for title from `on_load_changed`).
|
||||
|
||||
Remove `HISTORY_MAX_ENTRIES` (no cap — SQLite handles it).
|
||||
|
||||
## Phase 3: Migrate session to SQLite
|
||||
|
||||
### `src/db.h` / `src/db.c`
|
||||
|
||||
Add a `session` table:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
tab_index INTEGER PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Add functions:
|
||||
```c
|
||||
int db_session_save(const char **urls, const char **titles, int count);
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
int db_session_clear(void);
|
||||
```
|
||||
|
||||
`db_session_save` clears the table then inserts all current tabs. `db_session_load` reads them back in tab_index order.
|
||||
|
||||
### `src/session.h` / `src/session.c`
|
||||
|
||||
Rewrite `session_save()` and `session_restore()` to use the SQLite functions. Remove `session_path()` and `fopen()`.
|
||||
|
||||
## Phase 4: Migrate settings to SQLite
|
||||
|
||||
### `src/settings.h` / `src/settings.c`
|
||||
|
||||
Rewrite `settings_load()` and `settings_save()` to use `db_kv_get` / `db_kv_set` for each field. The `key_value` table already exists in the schema.
|
||||
|
||||
Each setting is stored as a key-value pair:
|
||||
- `restore_session` → `"true"` / `"false"`
|
||||
- `new_tab_url` → the URL string
|
||||
- `tab_bar_position` → `"top"` / `"bottom"` / etc.
|
||||
- `bootstrap_relays` → newline-separated URLs
|
||||
- etc.
|
||||
|
||||
**Important:** `settings_load()` must be called **after** `db_init()` (the DB must be open first). This changes the startup order in `main.c`:
|
||||
```
|
||||
settings_load() → db_init() → settings_load() (revised order)
|
||||
```
|
||||
Actually: `db_init()` first, then `settings_load()` reads from the DB.
|
||||
|
||||
### Remove `settings_path()` and `fopen()` from `settings.c`.
|
||||
|
||||
## Phase 5: Cleanup
|
||||
|
||||
- Delete `~/.sovereign_browser/identity.json` on startup (defensive)
|
||||
- Delete `~/.sovereign_browser/history.txt` on startup (one-time migration)
|
||||
- Delete `~/.sovereign_browser/session.txt` on startup (one-time migration)
|
||||
- Delete `~/.sovereign_browser/settings.conf` on startup (one-time migration)
|
||||
- Or: leave the old files in place (they're just ignored) — simpler, less destructive
|
||||
|
||||
## Startup order change in `main.c`
|
||||
|
||||
Current:
|
||||
```
|
||||
settings_load();
|
||||
history_load();
|
||||
db_init();
|
||||
```
|
||||
|
||||
New:
|
||||
```
|
||||
db_init(); /* open the database first */
|
||||
settings_load(); /* reads from key_value table */
|
||||
/* history_load() removed — history is queried from SQLite on demand */
|
||||
```
|
||||
|
||||
## File change summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/key_store.h` | Remove `key_store_save`, `key_store_load`, `key_store_clear`, `key_store_path` |
|
||||
| `src/key_store.c` | Remove file I/O functions; keep only `key_store_create_signer` |
|
||||
| `src/db.h` | Add `db_history_*`, `db_session_*` functions |
|
||||
| `src/db.c` | Add `history` + `session` tables to schema; implement new functions |
|
||||
| `src/history.h` | Update API (remove `history_load`, add title param) |
|
||||
| `src/history.c` | Rewrite to use SQLite; remove flat-file code |
|
||||
| `src/session.h` | No API change |
|
||||
| `src/session.c` | Rewrite to use SQLite; remove flat-file code |
|
||||
| `src/settings.h` | No API change |
|
||||
| `src/settings.c` | Rewrite to use `db_kv_get`/`db_kv_set`; remove flat-file code |
|
||||
| `src/main.c` | Reorder startup (`db_init` before `settings_load`); remove `key_store_clear` call; delete old files defensively |
|
||||
| `src/agent_login.c` | Remove `key_store_clear` call |
|
||||
| `src/cli.h` | Remove `no_save_identity` field |
|
||||
| `src/cli.c` | Remove `--no-save-identity` flag |
|
||||
@@ -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,482 @@
|
||||
/*
|
||||
* agent_login.c — agent-driven Nostr login for sovereign_browser
|
||||
*
|
||||
* Calls nostr_core_lib directly to authenticate, bypassing the GTK
|
||||
* login dialog. The agent provides credentials as JSON params and
|
||||
* gets back the pubkey/npub on success.
|
||||
*/
|
||||
|
||||
#include "agent_login.h"
|
||||
#include "key_store.h"
|
||||
#include "nostr_bridge.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <signal.h>
|
||||
|
||||
/* ── Global login state (shared with main.c) ──────────────────────── *
|
||||
* main.c owns the app_state_t (signer, pubkey, method, readonly).
|
||||
* We access it via extern functions that main.c provides.
|
||||
*/
|
||||
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
key_store_method_t method, gboolean readonly);
|
||||
extern void app_clear_signer(void);
|
||||
extern nostr_signer_t *app_get_signer(void);
|
||||
extern const char *app_get_pubkey_hex(void);
|
||||
extern key_store_method_t app_get_method(void);
|
||||
extern gboolean app_get_readonly(void);
|
||||
|
||||
/* Track whether the agent (not the GTK dialog) performed the login. */
|
||||
static gboolean g_agent_performed_login = FALSE;
|
||||
|
||||
/* ── Helper functions (same logic as login_dialog.c) ──────────────── */
|
||||
|
||||
static int derive_pubkey_hex(const unsigned char privkey[32], char pubkey_hex[65]) {
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) return -1;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
|
||||
if (strlen(hex) != 64) return -1;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
unsigned int byte;
|
||||
if (sscanf(hex + i * 2, "%02x", &byte) != 1) return -1;
|
||||
out[i] = (unsigned char)byte;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void pubkey_hex_to_npub(const char pubkey_hex[65], char npub[128]) {
|
||||
unsigned char pubkey[32];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
unsigned int b;
|
||||
sscanf(pubkey_hex + i * 2, "%02x", &b);
|
||||
pubkey[i] = (unsigned char)b;
|
||||
}
|
||||
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
|
||||
npub[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── JSON response helpers ────────────────────────────────────────── */
|
||||
|
||||
static cJSON *make_success(cJSON *data) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", TRUE);
|
||||
if (data) {
|
||||
cJSON_AddItemToObject(resp, "data", data);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
static cJSON *make_error(const char *code, const char *message) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", FALSE);
|
||||
cJSON *err = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(err, "code", code);
|
||||
cJSON_AddStringToObject(err, "message", message);
|
||||
cJSON_AddItemToObject(resp, "error", err);
|
||||
return resp;
|
||||
}
|
||||
|
||||
static cJSON *make_login_data(const char *method_str, const char *pubkey_hex,
|
||||
gboolean readonly) {
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "method", method_str);
|
||||
cJSON_AddStringToObject(data, "pubkey", pubkey_hex);
|
||||
|
||||
char npub[128];
|
||||
pubkey_hex_to_npub(pubkey_hex, npub);
|
||||
cJSON_AddStringToObject(data, "npub", npub);
|
||||
|
||||
cJSON_AddBoolToObject(data, "readonly", readonly);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ── Login method implementations ─────────────────────────────────── */
|
||||
|
||||
static cJSON *login_local(cJSON *params) {
|
||||
const char *nsec = cJSON_GetStringValue(cJSON_GetObjectItem(params, "nsec"));
|
||||
const char *privkey_hex = cJSON_GetStringValue(cJSON_GetObjectItem(params, "privkey_hex"));
|
||||
|
||||
unsigned char privkey[32];
|
||||
|
||||
if (nsec && nsec[0]) {
|
||||
if (strncmp(nsec, "nsec1", 5) == 0) {
|
||||
if (nostr_decode_nsec(nsec, privkey) != 0) {
|
||||
return make_error("INVALID_NSEC", "Failed to decode nsec string");
|
||||
}
|
||||
} else if (strlen(nsec) == 64) {
|
||||
if (hex_to_bytes32(nsec, privkey) != 0) {
|
||||
return make_error("INVALID_HEX", "Invalid hex private key");
|
||||
}
|
||||
} else {
|
||||
return make_error("INVALID_INPUT", "Enter an nsec1... or 64-char hex key");
|
||||
}
|
||||
} else if (privkey_hex && privkey_hex[0]) {
|
||||
if (hex_to_bytes32(privkey_hex, privkey) != 0) {
|
||||
return make_error("INVALID_HEX", "Invalid hex private key");
|
||||
}
|
||||
} else {
|
||||
return make_error("MISSING_PARAM", "Provide 'nsec' or 'privkey_hex'");
|
||||
}
|
||||
|
||||
char pubkey_hex[65];
|
||||
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
|
||||
return make_error("DERIVE_FAILED", "Failed to derive public key");
|
||||
}
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
|
||||
return make_success(make_login_data("local", pubkey_hex, FALSE));
|
||||
}
|
||||
|
||||
/* Generate a fresh Nostr keypair and log in with it.
|
||||
* This is the quickest way for an agent to get past the login screen
|
||||
* when it doesn't have a specific identity to use. The generated
|
||||
* nsec and pubkey are returned in the response so the agent can
|
||||
* save them if needed. */
|
||||
static cJSON *login_generate(cJSON *params) {
|
||||
(void)params; /* no parameters needed */
|
||||
|
||||
unsigned char privkey[32], pubkey[32];
|
||||
if (nostr_generate_keypair(privkey, pubkey) != 0) {
|
||||
return make_error("KEYGEN_FAILED", "Failed to generate keypair");
|
||||
}
|
||||
|
||||
/* Derive pubkey hex. */
|
||||
char pubkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
|
||||
/* Encode private key as nsec for the response. */
|
||||
char nsec[128];
|
||||
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
|
||||
nsec[0] = '\0';
|
||||
}
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
|
||||
|
||||
/* Include the nsec in the response so the agent can save it. */
|
||||
cJSON *data = make_login_data("generate", pubkey_hex, FALSE);
|
||||
cJSON_AddStringToObject(data, "nsec", nsec);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
static cJSON *login_seed(cJSON *params) {
|
||||
const char *mnemonic = cJSON_GetStringValue(cJSON_GetObjectItem(params, "mnemonic"));
|
||||
cJSON *account_json = cJSON_GetObjectItem(params, "account");
|
||||
int account = (account_json && cJSON_IsNumber(account_json))
|
||||
? account_json->valueint : 0;
|
||||
|
||||
if (!mnemonic || !mnemonic[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'mnemonic' (12 or 24 BIP-39 words)");
|
||||
}
|
||||
|
||||
unsigned char privkey[32], pubkey[32];
|
||||
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
|
||||
return make_error("INVALID_MNEMONIC", "Invalid seed phrase. Check the words and try again.");
|
||||
}
|
||||
|
||||
char pubkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
|
||||
return make_success(make_login_data("seed", pubkey_hex, FALSE));
|
||||
}
|
||||
|
||||
static cJSON *login_readonly(cJSON *params) {
|
||||
const char *npub = cJSON_GetStringValue(cJSON_GetObjectItem(params, "npub"));
|
||||
const char *pubkey_hex_in = cJSON_GetStringValue(cJSON_GetObjectItem(params, "pubkey_hex"));
|
||||
|
||||
unsigned char pubkey[32];
|
||||
char pubkey_hex[65];
|
||||
|
||||
if (npub && npub[0]) {
|
||||
if (strncmp(npub, "npub1", 5) == 0) {
|
||||
if (nostr_decode_npub(npub, pubkey) != 0) {
|
||||
return make_error("INVALID_NPUB", "Failed to decode npub string");
|
||||
}
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
} else if (strlen(npub) == 64) {
|
||||
if (hex_to_bytes32(npub, pubkey) != 0) {
|
||||
return make_error("INVALID_HEX", "Invalid hex pubkey");
|
||||
}
|
||||
memcpy(pubkey_hex, npub, 64);
|
||||
pubkey_hex[64] = '\0';
|
||||
} else {
|
||||
return make_error("INVALID_INPUT", "Enter an npub1... or 64-char hex pubkey");
|
||||
}
|
||||
} else if (pubkey_hex_in && pubkey_hex_in[0]) {
|
||||
if (hex_to_bytes32(pubkey_hex_in, pubkey) != 0) {
|
||||
return make_error("INVALID_HEX", "Invalid hex pubkey");
|
||||
}
|
||||
memcpy(pubkey_hex, pubkey_hex_in, 64);
|
||||
pubkey_hex[64] = '\0';
|
||||
} else {
|
||||
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
|
||||
}
|
||||
|
||||
/* Read-only: no signer created. */
|
||||
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
|
||||
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
|
||||
|
||||
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
|
||||
return make_success(make_login_data("readonly", pubkey_hex, TRUE));
|
||||
}
|
||||
|
||||
static cJSON *login_nip46(cJSON *params) {
|
||||
const char *bunker_url = cJSON_GetStringValue(cJSON_GetObjectItem(params, "bunker_url"));
|
||||
|
||||
if (!bunker_url || !bunker_url[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'bunker_url' (bunker://<pubkey>?relay=...&secret=...)");
|
||||
}
|
||||
|
||||
nostr_nip46_bunker_url_t bunker;
|
||||
memset(&bunker, 0, sizeof(bunker));
|
||||
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
|
||||
return make_error("INVALID_BUNKER", "Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
|
||||
}
|
||||
|
||||
/* Generate a client keypair for NIP-46 encryption. */
|
||||
unsigned char client_privkey[32], client_pubkey[32];
|
||||
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
|
||||
return make_error("KEYGEN_FAILED", "Failed to generate client keypair");
|
||||
}
|
||||
|
||||
char pubkey_hex[65];
|
||||
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
|
||||
pubkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(client_privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create client signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
|
||||
return make_success(make_login_data("nip46", pubkey_hex, FALSE));
|
||||
}
|
||||
|
||||
static cJSON *login_nsigner(cJSON *params) {
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
const char *transport = cJSON_GetStringValue(cJSON_GetObjectItem(params, "transport"));
|
||||
const char *device = cJSON_GetStringValue(cJSON_GetObjectItem(params, "device"));
|
||||
const char *service = cJSON_GetStringValue(cJSON_GetObjectItem(params, "service"));
|
||||
cJSON *index_json = cJSON_GetObjectItem(params, "index");
|
||||
int index = (index_json && cJSON_IsNumber(index_json)) ? index_json->valueint : 0;
|
||||
|
||||
if (!transport || !transport[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'transport' (serial/unix/tcp/qrexec)");
|
||||
}
|
||||
if (!device || !device[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'device' (path, socket name, host:port, or qube name)");
|
||||
}
|
||||
|
||||
/* Block SIGCHLD during n_signer calls (qrexec spawns children). */
|
||||
sigset_t block_set, old_set;
|
||||
sigemptyset(&block_set);
|
||||
sigaddset(&block_set, SIGCHLD);
|
||||
sigprocmask(SIG_BLOCK, &block_set, &old_set);
|
||||
|
||||
nostr_signer_t *signer = NULL;
|
||||
const char *transport_name = "unknown";
|
||||
|
||||
if (strcmp(transport, "serial") == 0) {
|
||||
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
|
||||
transport_name = "serial";
|
||||
} else if (strcmp(transport, "unix") == 0) {
|
||||
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
|
||||
transport_name = "unix";
|
||||
} else if (strcmp(transport, "tcp") == 0) {
|
||||
char host[256] = {0};
|
||||
int port = 7777;
|
||||
const char *sep = strchr(device, ':');
|
||||
if (sep) {
|
||||
size_t hlen = sep - device;
|
||||
if (hlen < sizeof(host)) {
|
||||
memcpy(host, device, hlen);
|
||||
host[hlen] = '\0';
|
||||
port = atoi(sep + 1);
|
||||
}
|
||||
} else {
|
||||
strncpy(host, device, sizeof(host) - 1);
|
||||
}
|
||||
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
|
||||
transport_name = "tcp";
|
||||
} else if (strcmp(transport, "qrexec") == 0) {
|
||||
const char *svc = (service && service[0]) ? service : "qubes.NsignerRpc";
|
||||
signer = nostr_signer_nsigner_qrexec(device, svc, NULL, 30000);
|
||||
transport_name = "qrexec";
|
||||
} else {
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return make_error("INVALID_TRANSPORT", "Unknown transport. Use: serial, unix, tcp, or qrexec");
|
||||
}
|
||||
|
||||
if (signer == NULL) {
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
|
||||
}
|
||||
|
||||
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
nostr_signer_free(signer);
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return make_error("NSIGNER_INDEX", "Failed to set nostr_index on n_signer.");
|
||||
}
|
||||
|
||||
char pubkey_hex[65];
|
||||
rc = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
nostr_signer_free(signer);
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
|
||||
}
|
||||
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
|
||||
transport_name, index, pubkey_hex);
|
||||
return make_success(make_login_data("nsigner", pubkey_hex, FALSE));
|
||||
#else
|
||||
(void)params;
|
||||
return make_error("NOT_COMPILED", "n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT)");
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────────── */
|
||||
|
||||
cJSON *agent_login_status(void) {
|
||||
gboolean logged_in = agent_is_logged_in();
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(data, "logged_in", logged_in);
|
||||
|
||||
if (logged_in) {
|
||||
const char *method_str = "none";
|
||||
switch (app_get_method()) {
|
||||
case KEY_STORE_METHOD_LOCAL: method_str = "local"; break;
|
||||
case KEY_STORE_METHOD_SEED: method_str = "seed"; break;
|
||||
case KEY_STORE_METHOD_READONLY: method_str = "readonly"; break;
|
||||
case KEY_STORE_METHOD_NIP46: method_str = "nip46"; break;
|
||||
case KEY_STORE_METHOD_NSIGNER: method_str = "nsigner"; break;
|
||||
default: break;
|
||||
}
|
||||
cJSON_AddStringToObject(data, "method", method_str);
|
||||
cJSON_AddStringToObject(data, "pubkey", app_get_pubkey_hex());
|
||||
cJSON_AddBoolToObject(data, "readonly", app_get_readonly());
|
||||
}
|
||||
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
cJSON *agent_login(cJSON *params) {
|
||||
if (params == NULL) {
|
||||
return make_error("MISSING_PARAMS", "No parameters provided");
|
||||
}
|
||||
|
||||
if (agent_is_logged_in()) {
|
||||
return make_error("ALREADY_LOGGED_IN", "Already logged in. Use switch_identity to change.");
|
||||
}
|
||||
|
||||
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(params, "method"));
|
||||
if (!method || !method[0]) {
|
||||
return make_error("MISSING_METHOD", "Provide 'method' (local/seed/readonly/nip46/nsigner/generate)");
|
||||
}
|
||||
|
||||
/* Initialize nostr_core_lib if not already done. */
|
||||
static gboolean nostr_initialized = FALSE;
|
||||
if (!nostr_initialized) {
|
||||
if (nostr_init() != NOSTR_SUCCESS) {
|
||||
return make_error("INIT_FAILED", "Failed to initialize nostr_core_lib");
|
||||
}
|
||||
nostr_initialized = TRUE;
|
||||
}
|
||||
|
||||
cJSON *result = NULL;
|
||||
if (strcmp(method, "local") == 0) {
|
||||
result = login_local(params);
|
||||
} else if (strcmp(method, "generate") == 0) {
|
||||
result = login_generate(params);
|
||||
} else if (strcmp(method, "seed") == 0) {
|
||||
result = login_seed(params);
|
||||
} else if (strcmp(method, "readonly") == 0) {
|
||||
result = login_readonly(params);
|
||||
} else if (strcmp(method, "nip46") == 0) {
|
||||
result = login_nip46(params);
|
||||
} else if (strcmp(method, "nsigner") == 0) {
|
||||
result = login_nsigner(params);
|
||||
} else {
|
||||
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, generate, seed, readonly, nip46, or nsigner");
|
||||
}
|
||||
|
||||
/* If login succeeded, mark that the agent performed it. */
|
||||
if (result && cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
|
||||
g_agent_performed_login = TRUE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
cJSON *agent_logout(void) {
|
||||
app_clear_signer();
|
||||
nostr_bridge_set_signer(NULL, "", TRUE);
|
||||
g_print("[agent-login] Logged out\n");
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
cJSON *agent_switch_identity(cJSON *params) {
|
||||
/* Free the old signer first. */
|
||||
app_clear_signer();
|
||||
/* Then login with the new identity. */
|
||||
return agent_login(params);
|
||||
}
|
||||
|
||||
gboolean agent_is_logged_in(void) {
|
||||
return (app_get_signer() != NULL) ||
|
||||
(app_get_method() == KEY_STORE_METHOD_READONLY && app_get_pubkey_hex()[0] != '\0');
|
||||
}
|
||||
|
||||
gboolean agent_login_was_performed_by_agent(void) {
|
||||
return g_agent_performed_login;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* agent_login.h — agent-driven Nostr login for sovereign_browser
|
||||
*
|
||||
* Calls nostr_core_lib directly, bypassing the GTK login dialog.
|
||||
* Used by the agent server so an AI agent can authenticate without
|
||||
* human interaction.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_LOGIN_H
|
||||
#define AGENT_LOGIN_H
|
||||
|
||||
#include <glib.h>
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Check current login state. Returns a JSON response (caller frees).
|
||||
* Response: {"success":true,"data":{"logged_in":true/false,"method":"local","pubkey":"...","readonly":false}}
|
||||
*/
|
||||
cJSON *agent_login_status(void);
|
||||
|
||||
/*
|
||||
* Perform login with the given method and params.
|
||||
* params is a cJSON object with "method" and method-specific fields:
|
||||
* - local: {"method":"local","nsec":"nsec1..."} or {"method":"local","privkey_hex":"..."}
|
||||
* - seed: {"method":"seed","mnemonic":"word1 word2 ...","account":0}
|
||||
* - readonly: {"method":"readonly","npub":"npub1..."} or {"method":"readonly","pubkey_hex":"..."}
|
||||
* - nip46: {"method":"nip46","bunker_url":"bunker://..."}
|
||||
* - nsigner: {"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}
|
||||
*
|
||||
* Returns JSON response (caller frees).
|
||||
* On success: {"success":true,"data":{"method":"local","pubkey":"...","npub":"...","readonly":false}}
|
||||
* On failure: {"success":false,"error":{"code":"...","message":"..."}}
|
||||
*/
|
||||
cJSON *agent_login(cJSON *params);
|
||||
|
||||
/*
|
||||
* Log out — clear signer, reset state. Returns JSON response (caller frees).
|
||||
*/
|
||||
cJSON *agent_logout(void);
|
||||
|
||||
/*
|
||||
* Switch identity — same as login but frees the old signer first.
|
||||
* Same params as agent_login. Returns JSON response (caller frees).
|
||||
*/
|
||||
cJSON *agent_switch_identity(cJSON *params);
|
||||
|
||||
/*
|
||||
* Returns TRUE if logged in (signer or readonly loaded).
|
||||
*/
|
||||
gboolean agent_is_logged_in(void);
|
||||
|
||||
/*
|
||||
* Returns TRUE if the agent (not the GTK dialog) performed the login.
|
||||
* Used by login_dialog.c to close the dialog when the agent logs in.
|
||||
*/
|
||||
gboolean agent_login_was_performed_by_agent(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_LOGIN_H */
|
||||
+902
@@ -0,0 +1,902 @@
|
||||
/*
|
||||
* agent_mcp.c — MCP (Model Context Protocol) server endpoint
|
||||
*
|
||||
* Implements the MCP Streamable HTTP transport. The AI assistant sends
|
||||
* JSON-RPC POST requests to /mcp, and we respond with JSON. This uses
|
||||
* the same agent_tools_dispatch() as the WebSocket endpoint.
|
||||
*
|
||||
* For async tools (snapshot, eval, etc.), we use a GMainLoop polling
|
||||
* approach to wait for the JS result, since we're in an HTTP handler
|
||||
* (not a WebSocket callback) so the polling doesn't deadlock.
|
||||
*/
|
||||
|
||||
#include "agent_mcp.h"
|
||||
#include "agent_server.h"
|
||||
#include "agent_tools.h"
|
||||
#include "agent_snapshot.h"
|
||||
#include "agent_login.h"
|
||||
#include "tab_manager.h"
|
||||
#include "version.h"
|
||||
|
||||
#include <libsoup/soup.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Session management ────────────────────────────────────────────── *
|
||||
*
|
||||
* MCP "Streamable HTTP" transport uses a Mcp-Session-Id header to
|
||||
* correlate requests. The server generates a UUID on `initialize`
|
||||
* and returns it in the response header. Subsequent requests should
|
||||
* carry the same header. Sessions expire after 1 hour of inactivity
|
||||
* (checked lazily on lookup).
|
||||
*/
|
||||
|
||||
#define MCP_SESSION_TIMEOUT_SEC 3600 /* 1 hour */
|
||||
|
||||
typedef struct {
|
||||
char *session_id;
|
||||
gboolean initialized;
|
||||
gint64 last_activity; /* time(NULL) */
|
||||
} mcp_session_t;
|
||||
|
||||
/* session_id (owned) -> mcp_session_t* (owned) */
|
||||
static GHashTable *g_sessions = NULL;
|
||||
|
||||
static mcp_session_t *session_create(void) {
|
||||
mcp_session_t *s = g_new(mcp_session_t, 1);
|
||||
s->session_id = g_uuid_string_random();
|
||||
s->initialized = TRUE;
|
||||
s->last_activity = time(NULL);
|
||||
g_hash_table_insert(g_sessions, s->session_id, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
static mcp_session_t *session_lookup(const char *id) {
|
||||
if (id == NULL || g_sessions == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
mcp_session_t *s = g_hash_table_lookup(g_sessions, id);
|
||||
if (s == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
/* Lazy expiry check. */
|
||||
if (time(NULL) - s->last_activity > MCP_SESSION_TIMEOUT_SEC) {
|
||||
g_hash_table_remove(g_sessions, id);
|
||||
return NULL;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static void session_touch(mcp_session_t *s) {
|
||||
if (s) {
|
||||
s->last_activity = time(NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static void session_destroy(mcp_session_t *s) {
|
||||
if (s && g_sessions) {
|
||||
g_hash_table_remove(g_sessions, s->session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── SSE helper ────────────────────────────────────────────────────── *
|
||||
*
|
||||
* Wraps a JSON-RPC response string in a single SSE event:
|
||||
* event: message\r\n
|
||||
* data: <json>\r\n
|
||||
* \r\n
|
||||
* Returns a newly-allocated string that the caller must free (or pass
|
||||
* to soup_server_message_set_response with SOUP_MEMORY_TAKE).
|
||||
*/
|
||||
static char *build_sse_response(const char *json_str) {
|
||||
if (json_str == NULL) {
|
||||
json_str = "{}";
|
||||
}
|
||||
return g_strdup_printf("event: message\r\ndata: %s\r\n\r\n", json_str);
|
||||
}
|
||||
|
||||
/* ── JSON-RPC helpers ─────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *rpc_result(int id, cJSON *result) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
|
||||
cJSON_AddNumberToObject(resp, "id", id);
|
||||
cJSON_AddItemToObject(resp, "result", result);
|
||||
return resp;
|
||||
}
|
||||
|
||||
static cJSON *rpc_error(int id, int code, const char *message) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
|
||||
cJSON_AddNumberToObject(resp, "id", id);
|
||||
cJSON *err = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(err, "code", code);
|
||||
cJSON_AddStringToObject(err, "message", message);
|
||||
cJSON_AddItemToObject(resp, "error", err);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/* ── Tool catalog ─────────────────────────────────────────────────── *
|
||||
* Static array of tool definitions. Each has a name, description,
|
||||
* and JSON Schema for input parameters.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *description;
|
||||
const char *schema_json; /* pre-built JSON schema string */
|
||||
} mcp_tool_def_t;
|
||||
|
||||
static mcp_tool_def_t tool_defs[] = {
|
||||
/* Login tools */
|
||||
{"login_status",
|
||||
"Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"login",
|
||||
"Log in to the browser with a Nostr identity. Methods: 'random' (generate a fresh random key — quickest for testing), 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
|
||||
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"random\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\",\"description\":\"nsec1... string (method: local)\"},\"privkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex private key (method: local)\"},\"mnemonic\":{\"type\":\"string\",\"description\":\"12 or 24 BIP-39 words (method: seed)\"},\"account\":{\"type\":\"integer\",\"default\":0,\"description\":\"Account index (method: seed)\"},\"npub\":{\"type\":\"string\",\"description\":\"npub1... string (method: readonly)\"},\"pubkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex pubkey (method: readonly)\"},\"bunker_url\":{\"type\":\"string\",\"description\":\"bunker:// URL (method: nip46)\"},\"transport\":{\"type\":\"string\",\"enum\":[\"serial\",\"unix\",\"tcp\",\"qrexec\"],\"description\":\"Transport type (method: nsigner)\"},\"device\":{\"type\":\"string\",\"description\":\"Device path, socket, host:port, or qube name (method: nsigner)\"},\"service\":{\"type\":\"string\",\"default\":\"qubes.NsignerRpc\",\"description\":\"Qrexec service name (method: nsigner)\"},\"index\":{\"type\":\"integer\",\"default\":0,\"description\":\"Key index (method: nsigner)\"}},\"required\":[\"method\"]}"},
|
||||
|
||||
{"logout",
|
||||
"Log out and clear the current Nostr identity.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"switch_identity",
|
||||
"Switch to a new Nostr identity. Same parameters as login (including 'generate'). Frees the old signer first.",
|
||||
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"generate\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\"},\"privkey_hex\":{\"type\":\"string\"},\"mnemonic\":{\"type\":\"string\"},\"npub\":{\"type\":\"string\"},\"bunker_url\":{\"type\":\"string\"},\"transport\":{\"type\":\"string\"},\"device\":{\"type\":\"string\"},\"index\":{\"type\":\"integer\"}},\"required\":[\"method\"]}"},
|
||||
|
||||
/* Navigation tools */
|
||||
{"open",
|
||||
"Navigate the active tab to a URL.",
|
||||
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}"},
|
||||
|
||||
{"back",
|
||||
"Go back in browser history.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"forward",
|
||||
"Go forward in browser history.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"reload",
|
||||
"Reload the current page (bypassing cache).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"get_url",
|
||||
"Get the current URL of the active tab.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"get_title",
|
||||
"Get the page title of the active tab.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Snapshot & inspection tools */
|
||||
{"snapshot",
|
||||
"Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements. Call this after navigation or page changes to see what's on the page.",
|
||||
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Only show interactive elements\"},\"compact\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Remove empty structural elements\"}}}"},
|
||||
|
||||
{"get_text",
|
||||
"Get the text content of an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\",\"description\":\"Element ref from snapshot (e.g. @e1)\"},\"selector\":{\"type\":\"string\",\"description\":\"CSS selector\"}}}"},
|
||||
|
||||
{"get_html",
|
||||
"Get the innerHTML of an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"get_attr",
|
||||
"Get an attribute of an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"attr\":{\"type\":\"string\",\"description\":\"Attribute name (e.g. href, src, class)\"}},\"required\":[\"attr\"]}"},
|
||||
|
||||
{"get_value",
|
||||
"Get the value of an input, textarea, or select element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"get_count",
|
||||
"Count the number of elements matching a CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
|
||||
|
||||
{"get_box",
|
||||
"Get the bounding box of an element by ref or CSS selector. Returns x, y, width, height, top, right, bottom, left.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"get_styles",
|
||||
"Get the computed CSS styles of an element by ref or CSS selector. Returns all computed style properties.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"is_visible",
|
||||
"Check if an element is visible (not display:none, visibility:hidden, opacity:0, or offsetParent null).",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"is_enabled",
|
||||
"Check if an element is enabled (not disabled).",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"is_checked",
|
||||
"Check if a checkbox or radio element is checked.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"eval",
|
||||
"Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
|
||||
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\",\"description\":\"JavaScript to execute\"}},\"required\":[\"script\"]}"},
|
||||
|
||||
{"screenshot",
|
||||
"Capture a screenshot of the current page as a PNG image. Returns base64-encoded image data that can be viewed by the AI assistant. Use for visual context when the accessibility tree snapshot isn't sufficient.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Interaction tools */
|
||||
{"click",
|
||||
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"click_at",
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"fill",
|
||||
"Clear an input and fill it with a value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
|
||||
{"type",
|
||||
"Type text into an element (appends to existing value).",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
|
||||
{"press",
|
||||
"Press a keyboard key (e.g. Enter, Tab, Escape).",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
|
||||
|
||||
{"scroll",
|
||||
"Scroll the page in a direction.",
|
||||
"{\"type\":\"object\",\"properties\":{\"direction\":{\"type\":\"string\",\"enum\":[\"up\",\"down\",\"left\",\"right\"]},\"amount\":{\"type\":\"integer\",\"default\":500}},\"required\":[\"direction\"]}"},
|
||||
|
||||
{"hover",
|
||||
"Hover over an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"focus",
|
||||
"Focus an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"close",
|
||||
"Close the active tab.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Tab tools */
|
||||
{"tab_list",
|
||||
"List all open tabs with their URLs and titles.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"tab_new",
|
||||
"Open a new tab, optionally with a URL.",
|
||||
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"tab_switch",
|
||||
"Switch to a tab by index.",
|
||||
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}},\"required\":[\"index\"]}"},
|
||||
|
||||
{"tab_close",
|
||||
"Close a tab by index. If no index, closes the active tab.",
|
||||
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}}}"},
|
||||
|
||||
/* Wait tools */
|
||||
{"wait",
|
||||
"Wait for a specified number of milliseconds.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ms\":{\"type\":\"integer\",\"default\":1000}}}"},
|
||||
|
||||
{"wait_for",
|
||||
"Wait for an element to appear on the page.",
|
||||
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"selector\"]}"},
|
||||
|
||||
/* Extended interaction tools */
|
||||
{"dblclick",
|
||||
"Double-click an element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"select",
|
||||
"Select an option in a dropdown element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
|
||||
{"check",
|
||||
"Check a checkbox element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"uncheck",
|
||||
"Uncheck a checkbox element by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"scroll_into_view",
|
||||
"Scroll an element into view by ref or CSS selector.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"keyboard_type",
|
||||
"Type text into the focused element with real keystroke events (keydown, keypress, keyup per character).",
|
||||
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
|
||||
{"insert_text",
|
||||
"Insert text at the current cursor position without key events. Uses document.execCommand.",
|
||||
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
|
||||
{"keydown",
|
||||
"Dispatch a keydown event for a key (hold key down).",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
|
||||
|
||||
{"keyup",
|
||||
"Dispatch a keyup event for a key (release key).",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
|
||||
|
||||
{"drag",
|
||||
"Drag an element and drop it onto another element (HTML5 drag events).",
|
||||
"{\"type\":\"object\",\"properties\":{\"src_ref\":{\"type\":\"string\"},\"src_selector\":{\"type\":\"string\"},\"tgt_ref\":{\"type\":\"string\"},\"tgt_selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"close_all",
|
||||
"Close all open tabs.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Find element tools */
|
||||
{"find_role",
|
||||
"Find an element by ARIA role (e.g. button, link, textbox, navigation). Optionally filter by name (aria-label or text content). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"role\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"}},\"required\":[\"role\"]}"},
|
||||
|
||||
{"find_text",
|
||||
"Find an element by text content. Set exact=true for exact match. Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"exact\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"text\"]}"},
|
||||
|
||||
{"find_label",
|
||||
"Find an element by associated label (label[for], aria-label, or aria-labelledby). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\"}},\"required\":[\"label\"]}"},
|
||||
|
||||
{"find_placeholder",
|
||||
"Find an element by placeholder text (substring match). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"placeholder\":{\"type\":\"string\"}},\"required\":[\"placeholder\"]}"},
|
||||
|
||||
{"find_alt",
|
||||
"Find an element by alt text (substring match). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"alt\":{\"type\":\"string\"}},\"required\":[\"alt\"]}"},
|
||||
|
||||
{"find_title",
|
||||
"Find an element by title attribute (substring match). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"]}"},
|
||||
|
||||
{"find_testid",
|
||||
"Find an element by data-testid attribute (exact match). Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"testid\":{\"type\":\"string\"}},\"required\":[\"testid\"]}"},
|
||||
|
||||
{"find_first",
|
||||
"Find the first element matching a CSS selector. Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
|
||||
|
||||
{"find_last",
|
||||
"Find the last element matching a CSS selector. Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
|
||||
|
||||
{"find_nth",
|
||||
"Find the nth element (0-based) matching a CSS selector. Returns a ref for use with click, fill, etc.",
|
||||
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"n\":{\"type\":\"integer\"}},\"required\":[\"selector\",\"n\"]}"},
|
||||
|
||||
{"wait_for_text",
|
||||
"Wait for specific text to appear on the page. Polls until the text is found in document.body.innerText or timeout.",
|
||||
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"text\"]}"},
|
||||
|
||||
{"wait_for_url",
|
||||
"Wait for the page URL to match a pattern. By default does a substring match; set regex=true for regex matching.",
|
||||
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000},\"regex\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"url\"]}"},
|
||||
|
||||
{"wait_for_load",
|
||||
"Wait for the page to finish loading (webkit_web_view_is_loading returns false).",
|
||||
"{\"type\":\"object\",\"properties\":{\"timeout\":{\"type\":\"integer\",\"default\":10000}}}"},
|
||||
|
||||
{"wait_for_fn",
|
||||
"Wait for a JavaScript expression to evaluate to truthy. The script is evaluated repeatedly until it returns true or timeout.",
|
||||
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"script\"]}"},
|
||||
|
||||
{"batch",
|
||||
"Execute multiple tool commands in sequence. Each command has 'tool', 'params', and optional 'id'. Set continueOnError=true to continue after failures.",
|
||||
"{\"type\":\"object\",\"properties\":{\"commands\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"tool\":{\"type\":\"string\"},\"params\":{\"type\":\"object\"},\"id\":{\"type\":[\"integer\",\"string\"]}}}},\"continueOnError\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"commands\"]}"},
|
||||
|
||||
/* Cookies & web storage tools */
|
||||
{"cookies_get",
|
||||
"Get all cookies for the current page (non-httpOnly cookies visible to JavaScript).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"cookies_set",
|
||||
"Set a cookie. Parameters: name, value, domain (optional), path (default /), secure, max_age (-1 for session cookie).",
|
||||
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"},\"domain\":{\"type\":\"string\"},\"path\":{\"type\":\"string\",\"default\":\"/\"},\"secure\":{\"type\":\"boolean\",\"default\":false},\"http_only\":{\"type\":\"boolean\",\"default\":false},\"max_age\":{\"type\":\"integer\",\"default\":-1}},\"required\":[\"name\",\"value\"]}"},
|
||||
|
||||
{"cookies_clear",
|
||||
"Clear all cookies for the current page.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"storage_local_get",
|
||||
"Get all localStorage entries as a JSON object.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"storage_local_get_key",
|
||||
"Get a specific localStorage key value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
|
||||
|
||||
{"storage_local_set",
|
||||
"Set a localStorage key to a value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
|
||||
|
||||
{"storage_local_clear",
|
||||
"Clear all localStorage entries.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"storage_session_get",
|
||||
"Get all sessionStorage entries as a JSON object.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"storage_session_get_key",
|
||||
"Get a specific sessionStorage key value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
|
||||
|
||||
{"storage_session_set",
|
||||
"Set a sessionStorage key to a value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
|
||||
|
||||
{"storage_session_clear",
|
||||
"Clear all sessionStorage entries.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Mouse tools */
|
||||
{"mouse_move",
|
||||
"Move the mouse to the specified coordinates (clientX, clientY).",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"mouse_down",
|
||||
"Press a mouse button at the specified coordinates. Button: left (default), middle, right.",
|
||||
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
|
||||
|
||||
{"mouse_up",
|
||||
"Release a mouse button at the specified coordinates. Button: left (default), middle, right.",
|
||||
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
|
||||
|
||||
{"mouse_wheel",
|
||||
"Scroll the mouse wheel by dy (vertical) and dx (horizontal) pixels.",
|
||||
"{\"type\":\"object\",\"properties\":{\"dy\":{\"type\":\"integer\"},\"dx\":{\"type\":\"integer\",\"default\":0}},\"required\":[\"dy\"]}"},
|
||||
|
||||
/* Clipboard tools */
|
||||
{"clipboard_read",
|
||||
"Read text from the system clipboard.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"clipboard_write",
|
||||
"Write text to the system clipboard.",
|
||||
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}"},
|
||||
|
||||
{"clipboard_copy",
|
||||
"Copy the current selection to clipboard (document.execCommand('copy')).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"clipboard_paste",
|
||||
"Paste from clipboard into the focused element (document.execCommand('paste')).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Settings tools */
|
||||
{"set_viewport",
|
||||
"Set the browser window/viewport size in pixels.",
|
||||
"{\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"}},\"required\":[\"width\",\"height\"]}"},
|
||||
|
||||
{"set_offline",
|
||||
"Toggle offline mode (not yet supported in WebKitGTK).",
|
||||
"{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true}}}"},
|
||||
|
||||
{"set_headers",
|
||||
"Set extra HTTP headers for all requests (not yet supported in WebKitGTK).",
|
||||
"{\"type\":\"object\",\"properties\":{\"headers\":{\"type\":\"object\"}}}"},
|
||||
|
||||
{"set_credentials",
|
||||
"Set HTTP basic auth credentials (not yet supported — auth is interactive in WebKitGTK).",
|
||||
"{\"type\":\"object\",\"properties\":{\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}},\"required\":[\"username\",\"password\"]}"},
|
||||
|
||||
{"set_media",
|
||||
"Emulate color scheme (dark or light).",
|
||||
"{\"type\":\"object\",\"properties\":{\"scheme\":{\"type\":\"string\",\"enum\":[\"dark\",\"light\"]}},\"required\":[\"scheme\"]}"},
|
||||
|
||||
/* Frame tools */
|
||||
{"frame_switch",
|
||||
"Switch to an iframe by ref or CSS selector. Subsequent JS-based tools will execute in the frame context.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"frame_main",
|
||||
"Switch back to the main frame (exit any iframe context).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Dialog tools */
|
||||
{"dialog_accept",
|
||||
"Accept a pending JavaScript dialog (alert, confirm, or prompt). For prompt dialogs, provide 'text' for the input value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"dialog_dismiss",
|
||||
"Dismiss a pending JavaScript dialog (cancel).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"dialog_status",
|
||||
"Check if a JavaScript dialog (alert/confirm/prompt) is pending. Returns the dialog type and message if pending.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
/* Debug tools */
|
||||
{"console",
|
||||
"Get collected console messages from the page. Set clear=true to clear after reading.",
|
||||
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
|
||||
|
||||
{"errors",
|
||||
"Get JavaScript errors from the page. Set clear=true to clear after reading.",
|
||||
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
|
||||
|
||||
{"highlight",
|
||||
"Highlight an element with a temporary red outline. Useful for debugging.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"duration\":{\"type\":\"integer\",\"default\":2000}}}"},
|
||||
|
||||
/* State tools */
|
||||
{"state_save",
|
||||
"Save browser state (localStorage and cookies) to a file or return as JSON string.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"state_load",
|
||||
"Load browser state from a file or JSON string. Restores localStorage and cookies.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"state\":{\"type\":\"string\"}}}"},
|
||||
|
||||
/* Complex tools */
|
||||
{"upload",
|
||||
"Upload files to a file input element. Provide file paths on the local filesystem. Files are read, base64-encoded, and set on the input via DataTransfer API.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"files\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"files\"]}"},
|
||||
|
||||
{"pdf",
|
||||
"Save the current page as a PDF file. Provide a file path for the output.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}"},
|
||||
|
||||
{"screenshot_annotated",
|
||||
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
|
||||
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
|
||||
};
|
||||
|
||||
static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]);
|
||||
|
||||
/* ── Build tools/list response ────────────────────────────────────── */
|
||||
|
||||
static cJSON *build_tools_list(void) {
|
||||
cJSON *tools = cJSON_CreateArray();
|
||||
for (int i = 0; i < tool_defs_count; i++) {
|
||||
cJSON *tool = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(tool, "name", tool_defs[i].name);
|
||||
cJSON_AddStringToObject(tool, "description", tool_defs[i].description);
|
||||
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
|
||||
if (schema) {
|
||||
cJSON_AddItemToObject(tool, "inputSchema", schema);
|
||||
}
|
||||
cJSON_AddItemToArray(tools, tool);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/* ── Async result holder (for JS-based tools) ─────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
cJSON *response;
|
||||
gboolean done;
|
||||
GMainLoop *loop;
|
||||
} mcp_async_ctx_t;
|
||||
|
||||
/* Callback for async JS evaluation — stores result and quits the loop. */
|
||||
static void mcp_async_callback(cJSON *response, gpointer user_data) {
|
||||
mcp_async_ctx_t *ctx = (mcp_async_ctx_t *)user_data;
|
||||
ctx->response = response;
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
}
|
||||
|
||||
/* We need a wrapper to adapt agent_js_eval_async's callback signature
|
||||
* to our mcp_async_ctx_t. The agent_js_eval_async sends the response
|
||||
* through a WebSocket connection, but for MCP we need to capture it.
|
||||
* Instead of using agent_js_eval_async, we'll call agent_tools_dispatch
|
||||
* with a special "capture" mode. */
|
||||
|
||||
/* Actually, the simplest approach for MCP: use agent_tools_dispatch
|
||||
* with NULL connection. When conn is NULL, the async tools fall through
|
||||
* to the sync path (which uses agent_js_eval_sync). The sync path
|
||||
* works fine in HTTP handlers because we're not inside a WebSocket
|
||||
* callback. Let's verify this works. */
|
||||
|
||||
/* ── MCP request handler ──────────────────────────────────────────── */
|
||||
|
||||
static void on_mcp_request(SoupServer *server,
|
||||
SoupServerMessage *msg,
|
||||
const char *path,
|
||||
GHashTable *query,
|
||||
gpointer user_data) {
|
||||
(void)server;
|
||||
(void)query;
|
||||
(void)user_data;
|
||||
|
||||
if (g_strcmp0(path, "/mcp") != 0) {
|
||||
soup_server_message_set_status(msg, 404, NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
const char *method = soup_server_message_get_method(msg);
|
||||
|
||||
/* ── GET: open an SSE stream for server→client push events ──── */
|
||||
if (g_strcmp0(method, "GET") == 0) {
|
||||
/* Pragmatic approach: return a valid SSE content-type with an
|
||||
* initial connection comment. A truly long-lived push stream
|
||||
* can be added later; for now this satisfies clients that
|
||||
* probe GET /mcp and expect text/event-stream. */
|
||||
soup_server_message_set_status(msg, 200, NULL);
|
||||
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
|
||||
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
|
||||
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
|
||||
const char *sse_init = ": connected\r\n\r\n";
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_STATIC, sse_init, strlen(sse_init));
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── DELETE: terminate a session ─────────────────────────────── */
|
||||
if (g_strcmp0(method, "DELETE") == 0) {
|
||||
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
|
||||
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
|
||||
if (sid == NULL) {
|
||||
soup_server_message_set_status(msg, 400, NULL);
|
||||
return;
|
||||
}
|
||||
mcp_session_t *s = session_lookup(sid);
|
||||
if (s == NULL) {
|
||||
soup_server_message_set_status(msg, 404, NULL);
|
||||
return;
|
||||
}
|
||||
session_destroy(s);
|
||||
soup_server_message_set_status(msg, 200, NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Only POST is handled below. */
|
||||
if (g_strcmp0(method, "POST") != 0) {
|
||||
soup_server_message_set_status(msg, 405, NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Read the request body. */
|
||||
SoupMessageBody *body = soup_server_message_get_request_body(msg);
|
||||
gsize size = body ? body->length : 0;
|
||||
const gchar *data = body ? body->data : NULL;
|
||||
if (data == NULL || size == 0) {
|
||||
soup_server_message_set_status(msg, 400, NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Parse JSON-RPC request. */
|
||||
cJSON *request = cJSON_ParseWithLength(data, size);
|
||||
if (request == NULL) {
|
||||
const char *err = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}";
|
||||
char *sse = build_sse_response(err);
|
||||
soup_server_message_set_status(msg, 200, NULL);
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_TAKE, sse, strlen(sse));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Extract JSON-RPC fields. */
|
||||
const char *rpc_method = cJSON_GetStringValue(cJSON_GetObjectItem(request, "method"));
|
||||
cJSON *id_json = cJSON_GetObjectItem(request, "id");
|
||||
cJSON *params = cJSON_GetObjectItem(request, "params");
|
||||
int rpc_id = (id_json && cJSON_IsNumber(id_json)) ? (int)id_json->valuedouble : 0;
|
||||
|
||||
/* ── Session validation ──────────────────────────────────────── *
|
||||
* For `initialize` we create a new session. For all other methods,
|
||||
* we check the Mcp-Session-Id header. If the header is present but
|
||||
* the session is unknown/expired, return 404. If the header is
|
||||
* absent, we proceed (lenient) for backward compatibility. */
|
||||
mcp_session_t *session = NULL;
|
||||
gboolean is_initialize = (rpc_method && strcmp(rpc_method, "initialize") == 0);
|
||||
|
||||
if (!is_initialize) {
|
||||
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
|
||||
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
|
||||
if (sid != NULL) {
|
||||
session = session_lookup(sid);
|
||||
if (session == NULL) {
|
||||
/* Unknown or expired session — create a new one and return
|
||||
* the new session ID in the response header so the client
|
||||
* can update its cached session ID. This handles browser
|
||||
* restarts gracefully without requiring the client to
|
||||
* re-initialize. */
|
||||
session = session_create();
|
||||
SoupMessageHeaders *resp_hdrs =
|
||||
soup_server_message_get_response_headers(msg);
|
||||
soup_message_headers_append(resp_hdrs,
|
||||
"Mcp-Session-Id", session->session_id);
|
||||
g_print("[mcp] Stale session '%s' — created new session '%s'\n",
|
||||
sid, session->session_id);
|
||||
}
|
||||
session_touch(session);
|
||||
} else {
|
||||
g_warning("[mcp] Request without Mcp-Session-Id header (method=%s) — proceeding leniently",
|
||||
rpc_method ? rpc_method : "?");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON *response = NULL;
|
||||
|
||||
if (rpc_method == NULL) {
|
||||
response = rpc_error(rpc_id, -32600, "Invalid Request");
|
||||
} else if (is_initialize) {
|
||||
/* Create a new session and return its ID in the response header. */
|
||||
session = session_create();
|
||||
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
|
||||
soup_message_headers_append(resp_hdrs, "Mcp-Session-Id", session->session_id);
|
||||
|
||||
/* Return server info and capabilities. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "protocolVersion", "2024-11-05");
|
||||
cJSON *caps = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(caps, "tools", cJSON_CreateObject());
|
||||
cJSON_AddItemToObject(result, "capabilities", caps);
|
||||
cJSON *server_info = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(server_info, "name", "sovereign-browser");
|
||||
cJSON_AddStringToObject(server_info, "version", SB_VERSION);
|
||||
cJSON_AddItemToObject(result, "serverInfo", server_info);
|
||||
response = rpc_result(rpc_id, result);
|
||||
} else if (strcmp(rpc_method, "tools/list") == 0) {
|
||||
/* Return the tool catalog. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(result, "tools", build_tools_list());
|
||||
response = rpc_result(rpc_id, result);
|
||||
} else if (strcmp(rpc_method, "tools/call") == 0) {
|
||||
/* Dispatch the tool call. */
|
||||
const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(params, "name"));
|
||||
cJSON *arguments = cJSON_GetObjectItem(params, "arguments");
|
||||
|
||||
if (tool_name == NULL) {
|
||||
response = rpc_error(rpc_id, -32602, "Missing 'name' in params");
|
||||
} else {
|
||||
/* Build a request in the format agent_tools_dispatch expects. */
|
||||
cJSON *tool_request = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(tool_request, "tool", tool_name);
|
||||
if (arguments) {
|
||||
cJSON_AddItemReferenceToObject(tool_request, "params", arguments);
|
||||
} else {
|
||||
cJSON_AddItemToObject(tool_request, "params", cJSON_CreateObject());
|
||||
}
|
||||
if (id_json) {
|
||||
cJSON_AddItemReferenceToObject(tool_request, "id", id_json);
|
||||
}
|
||||
|
||||
/* Call dispatch with NULL connection — async tools will
|
||||
* fall through to the sync path (agent_js_eval_sync),
|
||||
* which works in HTTP handlers. */
|
||||
cJSON *tool_response = agent_tools_dispatch(tool_request, NULL);
|
||||
cJSON_Delete(tool_request);
|
||||
|
||||
if (tool_response == NULL) {
|
||||
/* This shouldn't happen with NULL conn since async tools
|
||||
* fall back to sync. But handle it just in case. */
|
||||
response = rpc_error(rpc_id, -32603, "Tool returned no response");
|
||||
} else {
|
||||
/* Convert tool response to MCP format.
|
||||
* MCP tools/call returns: {content: [{type: "text", text: "..."}],
|
||||
* isError: false}
|
||||
*
|
||||
* For the screenshot tool, the response contains a
|
||||
* data.screenshot field with base64 PNG data. In that
|
||||
* case we emit an image content block per the MCP spec
|
||||
* instead of a text block. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON *content = cJSON_CreateArray();
|
||||
|
||||
cJSON *data_obj = cJSON_GetObjectItem(tool_response, "data");
|
||||
cJSON *screenshot = data_obj ? cJSON_GetObjectItem(data_obj, "screenshot") : NULL;
|
||||
|
||||
if (screenshot && cJSON_IsString(screenshot)) {
|
||||
/* Image content block per MCP spec. */
|
||||
cJSON *img_item = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(img_item, "type", "image");
|
||||
cJSON_AddStringToObject(img_item, "data", screenshot->valuestring);
|
||||
cJSON_AddStringToObject(img_item, "mimeType", "image/png");
|
||||
cJSON_AddItemToArray(content, img_item);
|
||||
|
||||
/* If the response also includes a snapshot text tree
|
||||
* (e.g. from screenshot_annotated), emit it as an
|
||||
* additional text content block. */
|
||||
cJSON *snapshot_text = cJSON_GetObjectItem(data_obj, "snapshot");
|
||||
if (snapshot_text && cJSON_IsString(snapshot_text)) {
|
||||
cJSON *text_item = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(text_item, "type", "text");
|
||||
cJSON_AddStringToObject(text_item, "text", snapshot_text->valuestring);
|
||||
cJSON_AddItemToArray(content, text_item);
|
||||
}
|
||||
} else {
|
||||
/* Default: text content block with the JSON response. */
|
||||
char *resp_str = cJSON_PrintUnformatted(tool_response);
|
||||
cJSON *text_item = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(text_item, "type", "text");
|
||||
cJSON_AddStringToObject(text_item, "text", resp_str ? resp_str : "{}");
|
||||
cJSON_AddItemToArray(content, text_item);
|
||||
free(resp_str);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(result, "content", content);
|
||||
|
||||
gboolean is_error = !cJSON_IsTrue(cJSON_GetObjectItem(tool_response, "success"));
|
||||
cJSON_AddBoolToObject(result, "isError", is_error);
|
||||
|
||||
cJSON_Delete(tool_response);
|
||||
response = rpc_result(rpc_id, result);
|
||||
}
|
||||
}
|
||||
} else if (strcmp(rpc_method, "ping") == 0) {
|
||||
/* MCP ping — health check. Return an empty result. */
|
||||
response = rpc_result(rpc_id, cJSON_CreateObject());
|
||||
} else if (strcmp(rpc_method, "resources/list") == 0) {
|
||||
/* We don't expose resources — return an empty list. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(result, "resources", cJSON_CreateArray());
|
||||
response = rpc_result(rpc_id, result);
|
||||
} else if (strcmp(rpc_method, "resources/templates/list") == 0) {
|
||||
/* No resource templates — return an empty list. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(result, "resourceTemplates", cJSON_CreateArray());
|
||||
response = rpc_result(rpc_id, result);
|
||||
} else if (strcmp(rpc_method, "prompts/list") == 0) {
|
||||
/* We don't expose prompts — return an empty list. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(result, "prompts", cJSON_CreateArray());
|
||||
response = rpc_result(rpc_id, result);
|
||||
} else if (strcmp(rpc_method, "logging/setLevel") == 0) {
|
||||
/* Accept any log level — we don't filter, but acknowledge. */
|
||||
response = rpc_result(rpc_id, cJSON_CreateObject());
|
||||
} else if (g_str_has_prefix(rpc_method, "notifications/")) {
|
||||
/* MCP notifications (initialized, cancelled, progress, etc.)
|
||||
* have no id and expect no response body. Return 202 Accepted. */
|
||||
cJSON_Delete(request);
|
||||
soup_server_message_set_status(msg, 202, NULL);
|
||||
return;
|
||||
} else if (id_json == NULL) {
|
||||
/* Any request without an id is a notification per JSON-RPC spec.
|
||||
* Return 202 Accepted with no body. */
|
||||
cJSON_Delete(request);
|
||||
soup_server_message_set_status(msg, 202, NULL);
|
||||
return;
|
||||
} else {
|
||||
response = rpc_error(rpc_id, -32601, "Method not found");
|
||||
}
|
||||
|
||||
cJSON_Delete(request);
|
||||
|
||||
/* Send the response as SSE (Server-Sent Events).
|
||||
* Notifications (no id) already returned 202 above and don't reach
|
||||
* here. All JSON-RPC responses with an id are wrapped in a single
|
||||
* SSE event: "event: message\r\ndata: <json>\r\n\r\n" */
|
||||
if (response) {
|
||||
char *resp_str = cJSON_PrintUnformatted(response);
|
||||
cJSON_Delete(response);
|
||||
if (resp_str) {
|
||||
char *sse = build_sse_response(resp_str);
|
||||
free(resp_str);
|
||||
soup_server_message_set_status(msg, 200, NULL);
|
||||
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
|
||||
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
|
||||
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_TAKE, sse, strlen(sse));
|
||||
} else {
|
||||
soup_server_message_set_status(msg, 500, NULL);
|
||||
}
|
||||
} else {
|
||||
soup_server_message_set_status(msg, 500, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────────── */
|
||||
|
||||
void agent_mcp_register(SoupServer *server) {
|
||||
/* Initialize the session table if not yet created. */
|
||||
if (g_sessions == NULL) {
|
||||
g_sessions = g_hash_table_new_full(g_str_hash, g_str_equal,
|
||||
g_free, g_free);
|
||||
}
|
||||
soup_server_add_handler(server, "/mcp", on_mcp_request, NULL, NULL);
|
||||
g_print("[agent] MCP endpoint: http://localhost:%d/mcp\n",
|
||||
agent_server_get_port());
|
||||
}
|
||||
@@ -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,449 @@
|
||||
/*
|
||||
* agent_snapshot.c — accessibility tree snapshot for agent tools
|
||||
*
|
||||
* Injects a JS script that walks the DOM, assigns refs to interactive
|
||||
* elements, and returns a text tree. The ref map is stored in
|
||||
* window.__agentRefs for later use by click/fill/type tools.
|
||||
*/
|
||||
|
||||
#include "agent_snapshot.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
|
||||
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
|
||||
* on the default context to wait for the result. The key is to acquire
|
||||
* the context before creating the loop so that the WebKit IPC sources
|
||||
* are properly dispatched.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
GMainLoop *loop;
|
||||
char *result;
|
||||
gboolean done;
|
||||
} js_eval_ctx_t;
|
||||
|
||||
static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
|
||||
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
|
||||
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
|
||||
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
|
||||
if (value != NULL) {
|
||||
char *str = jsc_value_to_string(value);
|
||||
if (str != NULL) {
|
||||
ctx->result = g_strdup(str);
|
||||
free(str);
|
||||
}
|
||||
g_object_unref(value);
|
||||
}
|
||||
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean on_js_timeout(gpointer user_data) {
|
||||
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] JS evaluation timed out\n");
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
|
||||
int timeout_ms) {
|
||||
if (webview == NULL || script == NULL) return NULL;
|
||||
|
||||
js_eval_ctx_t ctx = {0};
|
||||
|
||||
/* Use the default main context for the nested loop. */
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
|
||||
/* Add a timeout source to the same context. */
|
||||
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
|
||||
|
||||
/* Start async evaluation. */
|
||||
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
|
||||
on_js_finished, &ctx);
|
||||
|
||||
/* Run the nested loop until JS completes or timeout. */
|
||||
g_main_loop_run(ctx.loop);
|
||||
|
||||
/* Cleanup. */
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
/* ── Async JS evaluation ──────────────────────────────────────────── *
|
||||
* For tools that need JS return values (snapshot, eval, get_text, etc.),
|
||||
* we use async evaluation. The JS completion callback sends the
|
||||
* WebSocket response directly, avoiding the nested main loop issue.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
SoupWebsocketConnection *conn;
|
||||
int request_id;
|
||||
char *tool_name;
|
||||
js_result_handler_t handler;
|
||||
} async_js_ctx_t;
|
||||
|
||||
static void send_ws_response(SoupWebsocketConnection *conn, cJSON *response) {
|
||||
if (conn == NULL || response == NULL) return;
|
||||
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
|
||||
cJSON_Delete(response);
|
||||
return;
|
||||
}
|
||||
char *str = cJSON_PrintUnformatted(response);
|
||||
if (str) {
|
||||
soup_websocket_connection_send_text(conn, str);
|
||||
free(str);
|
||||
}
|
||||
cJSON_Delete(response);
|
||||
}
|
||||
|
||||
static cJSON *make_success_resp(int id, cJSON *data) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", TRUE);
|
||||
cJSON_AddNumberToObject(resp, "id", id);
|
||||
if (data) cJSON_AddItemToObject(resp, "data", data);
|
||||
return resp;
|
||||
}
|
||||
|
||||
static cJSON *make_error_resp(int id, const char *code, const char *msg) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", FALSE);
|
||||
cJSON_AddNumberToObject(resp, "id", id);
|
||||
cJSON *err = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(err, "code", code);
|
||||
cJSON_AddStringToObject(err, "message", msg);
|
||||
cJSON_AddItemToObject(resp, "error", err);
|
||||
return resp;
|
||||
}
|
||||
|
||||
static void on_async_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
|
||||
async_js_ctx_t *ctx = (async_js_ctx_t *)user_data;
|
||||
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
|
||||
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
|
||||
if (value == NULL) {
|
||||
send_ws_response(ctx->conn,
|
||||
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS evaluation returned NULL"));
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
char *str = jsc_value_to_string(value);
|
||||
g_object_unref(value);
|
||||
|
||||
if (str == NULL) {
|
||||
send_ws_response(ctx->conn,
|
||||
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS returned null"));
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
char *result = g_strdup(str);
|
||||
free(str);
|
||||
|
||||
if (ctx->handler) {
|
||||
/* Let the tool-specific handler transform the result. */
|
||||
cJSON *response = ctx->handler(result);
|
||||
g_free(result);
|
||||
if (response) {
|
||||
/* Add the request id. */
|
||||
cJSON_AddNumberToObject(response, "id", ctx->request_id);
|
||||
send_ws_response(ctx->conn, response);
|
||||
} else {
|
||||
send_ws_response(ctx->conn,
|
||||
make_error_resp(ctx->request_id, "TOOL_FAILED", "Tool handler returned NULL"));
|
||||
}
|
||||
} else {
|
||||
/* No handler — return raw result. */
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "result", result);
|
||||
g_free(result);
|
||||
send_ws_response(ctx->conn, make_success_resp(ctx->request_id, data));
|
||||
}
|
||||
|
||||
cleanup:
|
||||
g_free(ctx->tool_name);
|
||||
g_free(ctx);
|
||||
}
|
||||
|
||||
gboolean agent_js_eval_async(WebKitWebView *webview,
|
||||
const char *script,
|
||||
SoupWebsocketConnection *conn,
|
||||
int request_id,
|
||||
const char *tool_name,
|
||||
js_result_handler_t handler) {
|
||||
if (webview == NULL || script == NULL) return FALSE;
|
||||
|
||||
async_js_ctx_t *ctx = g_new0(async_js_ctx_t, 1);
|
||||
ctx->conn = conn;
|
||||
ctx->request_id = request_id;
|
||||
ctx->tool_name = g_strdup(tool_name ? tool_name : "unknown");
|
||||
ctx->handler = handler;
|
||||
|
||||
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
|
||||
on_async_js_finished, ctx);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* ── Snapshot JS script ───────────────────────────────────────────── *
|
||||
* This script walks the DOM, assigns refs to interactive elements,
|
||||
* stores the ref map in window.__agentRefs, and returns a JSON string
|
||||
* with the tree text and ref metadata.
|
||||
*/
|
||||
|
||||
const char *AGENT_SNAPSHOT_JS =
|
||||
"(function() {\n"
|
||||
" 'use strict';\n"
|
||||
"\n"
|
||||
" var interactiveOnly = arguments[0] || false;\n"
|
||||
" var compact = arguments[1] || false;\n"
|
||||
"\n"
|
||||
" var refMap = {};\n"
|
||||
" var refCount = 0;\n"
|
||||
" var treeLines = [];\n"
|
||||
"\n"
|
||||
" /* Check if an element is interactive. */\n"
|
||||
" function isInteractive(el) {\n"
|
||||
" var tag = el.tagName.toLowerCase();\n"
|
||||
" if (tag === 'a' && el.href) return true;\n"
|
||||
" if (tag === 'button') return true;\n"
|
||||
" if (tag === 'input' && el.type !== 'hidden') return true;\n"
|
||||
" if (tag === 'select') return true;\n"
|
||||
" if (tag === 'textarea') return true;\n"
|
||||
" if (tag === 'summary') return true;\n"
|
||||
" if (tag === 'details') return true;\n"
|
||||
" if (el.hasAttribute('role')) {\n"
|
||||
" var role = el.getAttribute('role');\n"
|
||||
" if (['button','link','checkbox','radio','textbox','searchbox',\n"
|
||||
" 'slider','spinbutton','tab','tablist','menuitem','combobox',\n"
|
||||
" 'option','switch','treeitem'].indexOf(role) >= 0) return true;\n"
|
||||
" }\n"
|
||||
" if (el.hasAttribute('tabindex') && el.tabIndex >= 0) return true;\n"
|
||||
" if (el.hasAttribute('onclick')) return true;\n"
|
||||
" return false;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Get the accessible name for an element. */\n"
|
||||
" function getName(el) {\n"
|
||||
" if (el.hasAttribute('aria-label')) return el.getAttribute('aria-label');\n"
|
||||
" if (el.hasAttribute('aria-labelledby')) {\n"
|
||||
" var lbl = document.getElementById(el.getAttribute('aria-labelledby'));\n"
|
||||
" if (lbl) return lbl.textContent.trim();\n"
|
||||
" }\n"
|
||||
" if (el.tagName.toLowerCase() === 'input' || el.tagName.toLowerCase() === 'textarea') {\n"
|
||||
" if (el.hasAttribute('placeholder')) return el.placeholder;\n"
|
||||
" var lbl2 = document.querySelector('label[for=\"' + el.id + '\"]');\n"
|
||||
" if (lbl2) return lbl2.textContent.trim();\n"
|
||||
" }\n"
|
||||
" if (el.tagName.toLowerCase() === 'select') {\n"
|
||||
" if (el.options.length > 0) return el.options[el.selectedIndex].text;\n"
|
||||
" }\n"
|
||||
" var text = el.textContent.trim();\n"
|
||||
" if (text.length > 80) text = text.substring(0, 77) + '...';\n"
|
||||
" return text;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Get the role for an element. */\n"
|
||||
" function getRole(el) {\n"
|
||||
" if (el.hasAttribute('role')) return el.getAttribute('role');\n"
|
||||
" var tag = el.tagName.toLowerCase();\n"
|
||||
" var map = {\n"
|
||||
" 'a': 'link', 'button': 'button', 'input': 'textbox',\n"
|
||||
" 'select': 'combobox', 'textarea': 'textbox', 'img': 'image',\n"
|
||||
" 'h1': 'heading', 'h2': 'heading', 'h3': 'heading',\n"
|
||||
" 'h4': 'heading', 'h5': 'heading', 'h6': 'heading',\n"
|
||||
" 'nav': 'navigation', 'main': 'main', 'article': 'article',\n"
|
||||
" 'section': 'region', 'form': 'form', 'ul': 'list',\n"
|
||||
" 'ol': 'list', 'li': 'listitem', 'table': 'table',\n"
|
||||
" 'label': 'label', 'p': 'paragraph', 'summary': 'button'\n"
|
||||
" };\n"
|
||||
" if (tag === 'input') {\n"
|
||||
" var type = (el.type || 'text').toLowerCase();\n"
|
||||
" if (type === 'checkbox') return 'checkbox';\n"
|
||||
" if (type === 'radio') return 'radio';\n"
|
||||
" if (type === 'button' || type === 'submit' || type === 'reset') return 'button';\n"
|
||||
" if (type === 'search') return 'searchbox';\n"
|
||||
" if (type === 'range') return 'slider';\n"
|
||||
" if (type === 'number') return 'spinbutton';\n"
|
||||
" return 'textbox';\n"
|
||||
" }\n"
|
||||
" return map[tag] || 'generic';\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Generate a unique CSS selector for an element. */\n"
|
||||
" function getSelector(el) {\n"
|
||||
" if (el.id) return '#' + el.id;\n"
|
||||
" var path = [];\n"
|
||||
" while (el && el.nodeType === 1 && el !== document.body) {\n"
|
||||
" var part = el.tagName.toLowerCase();\n"
|
||||
" if (el.className && typeof el.className === 'string') {\n"
|
||||
" var classes = el.className.trim().split(/\\s+/).slice(0, 2);\n"
|
||||
" if (classes.length > 0 && classes[0]) part += '.' + classes.join('.');\n"
|
||||
" }\n"
|
||||
" var parent = el.parentNode;\n"
|
||||
" if (parent) {\n"
|
||||
" var siblings = Array.prototype.filter.call(parent.children,\n"
|
||||
" function(c) { return c.tagName === el.tagName; });\n"
|
||||
" if (siblings.length > 1) {\n"
|
||||
" part += ':nth-of-type(' + (siblings.indexOf(el) + 1) + ')';\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" path.unshift(part);\n"
|
||||
" el = parent;\n"
|
||||
" }\n"
|
||||
" return path.length > 0 ? path.join(' > ') : 'body';\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Walk the DOM. */\n"
|
||||
" function walk(el, depth) {\n"
|
||||
" if (!el || el.nodeType !== 1) return;\n"
|
||||
"\n"
|
||||
" var role = getRole(el);\n"
|
||||
" var name = getName(el);\n"
|
||||
" var interactive = isInteractive(el);\n"
|
||||
"\n"
|
||||
" /* Skip non-interactive elements in interactive-only mode. */\n"
|
||||
" if (interactiveOnly && !interactive) {\n"
|
||||
" /* Still walk children — they might be interactive. */\n"
|
||||
" for (var i = 0; i < el.children.length; i++) {\n"
|
||||
" walk(el.children[i], depth);\n"
|
||||
" }\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Skip empty elements in compact mode. */\n"
|
||||
" if (compact && !interactive && !name && el.children.length === 0) return;\n"
|
||||
"\n"
|
||||
" var indent = '';\n"
|
||||
" for (var d = 0; d < depth; d++) indent += ' ';\n"
|
||||
"\n"
|
||||
" var line = indent + '- ' + role;\n"
|
||||
" if (name) line += ' \"' + name + '\"';\n"
|
||||
"\n"
|
||||
" if (interactive) {\n"
|
||||
" refCount++;\n"
|
||||
" var ref = 'e' + refCount;\n"
|
||||
" var selector = getSelector(el);\n"
|
||||
" var rect = el.getBoundingClientRect();\n"
|
||||
" refMap[ref] = {\n"
|
||||
" role: role,\n"
|
||||
" name: name,\n"
|
||||
" selector: selector,\n"
|
||||
" tag: el.tagName.toLowerCase(),\n"
|
||||
" href: el.href || '',\n"
|
||||
" type: el.type || '',\n"
|
||||
" value: el.value || '',\n"
|
||||
" bbox: {\n"
|
||||
" x: rect.x,\n"
|
||||
" y: rect.y,\n"
|
||||
" width: rect.width,\n"
|
||||
" height: rect.height\n"
|
||||
" }\n"
|
||||
" };\n"
|
||||
" line += ' [ref=' + ref + ']';\n"
|
||||
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"
|
||||
" line += ' [type=' + el.type + ']';\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (el.tagName && el.tagName.match(/^H[1-6]$/)) {\n"
|
||||
" line += ' [level=' + el.tagName[1] + ']';\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" treeLines.push(line);\n"
|
||||
"\n"
|
||||
" for (var i = 0; i < el.children.length; i++) {\n"
|
||||
" walk(el.children[i], depth + 1);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* Start from body. */\n"
|
||||
" walk(document.body, 0);\n"
|
||||
"\n"
|
||||
" /* Store ref map globally for later use by click/fill tools. */\n"
|
||||
" window.__agentRefs = refMap;\n"
|
||||
"\n"
|
||||
" /* Return JSON result. */\n"
|
||||
" return JSON.stringify({\n"
|
||||
" snapshot: treeLines.join('\\\\n'),\n"
|
||||
" refs: refMap,\n"
|
||||
" refCount: refCount\n"
|
||||
" });\n"
|
||||
"})\n";
|
||||
|
||||
/* ── Snapshot API ─────────────────────────────────────────────────── */
|
||||
|
||||
cJSON *agent_snapshot_take(WebKitWebView *webview,
|
||||
gboolean interactive,
|
||||
gboolean compact) {
|
||||
if (webview == NULL) return NULL;
|
||||
|
||||
/* Build the JS call with arguments. Use g_strconcat instead of
|
||||
* g_strdup_printf to avoid % format specifier issues in the JS. */
|
||||
char *full_script = g_strconcat(
|
||||
"(", AGENT_SNAPSHOT_JS, ")(",
|
||||
interactive ? "true" : "false", ", ",
|
||||
compact ? "true" : "false", ")",
|
||||
NULL);
|
||||
|
||||
char *result = agent_js_eval_sync(webview, full_script, 15000);
|
||||
g_free(full_script);
|
||||
|
||||
if (result == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON *json = cJSON_Parse(result);
|
||||
g_free(result);
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
/* ── Async snapshot ───────────────────────────────────────────────── */
|
||||
|
||||
/* Handler that transforms the snapshot JS result into a cJSON response. */
|
||||
static cJSON *snapshot_result_handler(const char *js_result) {
|
||||
if (js_result == NULL || js_result[0] == '\0') {
|
||||
return make_error_resp(0, "SNAPSHOT_FAILED", "Snapshot returned empty result");
|
||||
}
|
||||
|
||||
cJSON *json = cJSON_Parse(js_result);
|
||||
if (json == NULL) {
|
||||
return make_error_resp(0, "SNAPSHOT_FAILED", "Failed to parse snapshot JSON");
|
||||
}
|
||||
|
||||
return make_success_resp(0, json);
|
||||
}
|
||||
|
||||
gboolean agent_snapshot_take_async(WebKitWebView *webview,
|
||||
gboolean interactive,
|
||||
gboolean compact,
|
||||
SoupWebsocketConnection *conn,
|
||||
int request_id) {
|
||||
if (webview == NULL) return FALSE;
|
||||
|
||||
char *full_script = g_strconcat(
|
||||
"(", AGENT_SNAPSHOT_JS, ")(",
|
||||
interactive ? "true" : "false", ", ",
|
||||
compact ? "true" : "false", ")",
|
||||
NULL);
|
||||
|
||||
gboolean started = agent_js_eval_async(webview, full_script, conn,
|
||||
request_id, "snapshot",
|
||||
snapshot_result_handler);
|
||||
g_free(full_script);
|
||||
return started;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* agent_snapshot.h — accessibility tree snapshot for agent tools
|
||||
*
|
||||
* Injects a JavaScript script into the active tab's webview that walks
|
||||
* the DOM, assigns sequential refs (e1, e2, ...) to interactive elements,
|
||||
* and returns a text representation of the accessibility tree.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_SNAPSHOT_H
|
||||
#define AGENT_SNAPSHOT_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
#include <libsoup/soup.h>
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* The JS script injected for snapshot. It walks the DOM, assigns refs,
|
||||
* stores the ref map in window.__agentRefs, and returns a JSON string
|
||||
* with the tree text and ref metadata.
|
||||
*/
|
||||
extern const char *AGENT_SNAPSHOT_JS;
|
||||
|
||||
/*
|
||||
* Take a snapshot of the given webview. This injects the snapshot JS
|
||||
* and returns the result as a cJSON object:
|
||||
* {"snapshot": "- heading ... [ref=e1]\n- link ... [ref=e2]",
|
||||
* "refs": {"e1": {"role":"heading","name":"..."}, ...}}
|
||||
*
|
||||
* Returns NULL on failure. Caller frees the result.
|
||||
*
|
||||
* interactive: if TRUE, only include interactive elements
|
||||
* compact: if TRUE, remove empty structural elements
|
||||
*/
|
||||
cJSON *agent_snapshot_take(WebKitWebView *webview,
|
||||
gboolean interactive,
|
||||
gboolean compact);
|
||||
|
||||
/*
|
||||
* Async snapshot — starts JS evaluation and sends the response through
|
||||
* the WebSocket connection when the JS completes. Returns TRUE if the
|
||||
* async evaluation was started (response will be sent async), FALSE if
|
||||
* it failed immediately (caller should send an error response).
|
||||
*
|
||||
* conn: WebSocket connection to send the response through
|
||||
* request_id: the JSON request id to include in the response
|
||||
*/
|
||||
gboolean agent_snapshot_take_async(WebKitWebView *webview,
|
||||
gboolean interactive,
|
||||
gboolean compact,
|
||||
SoupWebsocketConnection *conn,
|
||||
int request_id);
|
||||
|
||||
/*
|
||||
* Async JS evaluation — evaluates a script and sends the result as a
|
||||
* tool response through the WebSocket connection when the JS completes.
|
||||
* Returns TRUE if started async, FALSE on immediate failure.
|
||||
*
|
||||
* conn: WebSocket connection to send the response through
|
||||
* request_id: the JSON request id to include in the response
|
||||
* tool_name: the tool name (for logging)
|
||||
* success_handler: optional callback to transform the JS result into
|
||||
* a cJSON response before sending. If NULL, the raw
|
||||
* JS result string is returned in data.result.
|
||||
*/
|
||||
typedef cJSON *(*js_result_handler_t)(const char *js_result);
|
||||
|
||||
gboolean agent_js_eval_async(WebKitWebView *webview,
|
||||
const char *script,
|
||||
SoupWebsocketConnection *conn,
|
||||
int request_id,
|
||||
const char *tool_name,
|
||||
js_result_handler_t handler);
|
||||
|
||||
/*
|
||||
* Execute JavaScript in the webview and wait for the result.
|
||||
* This is a synchronous wrapper around the async evaluate_javascript API.
|
||||
* Returns the result string (caller must free) or NULL on failure/timeout.
|
||||
*
|
||||
* script: the JavaScript to execute (must return a string)
|
||||
* timeout_ms: how long to wait for the result
|
||||
*/
|
||||
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
|
||||
int timeout_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_SNAPSHOT_H */
|
||||
+5813
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 */
|
||||
+587
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* bookmarks.c — NIP-44 encrypted Nostr bookmarks with directories
|
||||
*
|
||||
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
|
||||
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
|
||||
* and stored in the event's content field as a JSON tag array:
|
||||
*
|
||||
* [["bookmark", "https://example.com", "Example", 1690000000], ...]
|
||||
*
|
||||
* The encrypted event is published to the bootstrap relays and stored in
|
||||
* the local SQLite database. On startup, the relay fetch retrieves kind
|
||||
* 30003 events, which are decrypted and loaded into the in-memory list.
|
||||
*/
|
||||
|
||||
#include "bookmarks.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Global state ──────────────────────────────────────────────────── */
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static int g_have_signer = 0;
|
||||
|
||||
static bookmark_dir_t *g_dirs = NULL;
|
||||
static int g_dir_count = 0;
|
||||
static int g_dir_cap = 0;
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* Find a directory by name. Returns the index, or -1 if not found. */
|
||||
static int find_dir(const char *name) {
|
||||
if (name == NULL) return -1;
|
||||
for (int i = 0; i < g_dir_count; i++) {
|
||||
if (strcmp(g_dirs[i].name, name) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Ensure a directory exists in the in-memory list. Returns the index. */
|
||||
static int ensure_dir(const char *name) {
|
||||
int idx = find_dir(name);
|
||||
if (idx >= 0) return idx;
|
||||
|
||||
/* Grow the array if needed. */
|
||||
if (g_dir_count >= g_dir_cap) {
|
||||
int new_cap = g_dir_cap == 0 ? 8 : g_dir_cap * 2;
|
||||
bookmark_dir_t *new_arr = g_realloc(g_dirs, new_cap * sizeof(*new_arr));
|
||||
if (new_arr == NULL) return -1;
|
||||
g_dirs = new_arr;
|
||||
g_dir_cap = new_cap;
|
||||
}
|
||||
|
||||
idx = g_dir_count++;
|
||||
memset(&g_dirs[idx], 0, sizeof(g_dirs[idx]));
|
||||
snprintf(g_dirs[idx].name, sizeof(g_dirs[idx].name), "%s", name);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/* Free the bookmarks in a directory (but not the directory struct itself). */
|
||||
static void free_dir_bookmarks(bookmark_dir_t *dir) {
|
||||
if (dir->bookmarks == NULL) return;
|
||||
for (int i = 0; i < dir->count; i++) {
|
||||
g_free(dir->bookmarks[i].url);
|
||||
g_free(dir->bookmarks[i].title);
|
||||
}
|
||||
g_free(dir->bookmarks);
|
||||
dir->bookmarks = NULL;
|
||||
dir->count = 0;
|
||||
}
|
||||
|
||||
/* Find a bookmark in a directory by URL. Returns the index, or -1. */
|
||||
static int find_bookmark_in_dir(const bookmark_dir_t *dir, const char *url) {
|
||||
if (dir == NULL || url == NULL) return -1;
|
||||
for (int i = 0; i < dir->count; i++) {
|
||||
if (strcmp(dir->bookmarks[i].url, url) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ── Serialization ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON.
|
||||
* Returns a newly allocated string (caller must g_free). */
|
||||
static char *dir_to_json(const bookmark_dir_t *dir) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
for (int i = 0; i < dir->count; i++) {
|
||||
cJSON *tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString("bookmark"));
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].url));
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].title));
|
||||
cJSON_AddItemToArray(tag,
|
||||
cJSON_CreateNumber(dir->bookmarks[i].added));
|
||||
cJSON_AddItemToArray(arr, tag);
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(arr);
|
||||
cJSON_Delete(arr);
|
||||
return json;
|
||||
}
|
||||
|
||||
/* Parse a NIP-51 tag-array JSON string into a directory's bookmarks.
|
||||
* Replaces the directory's current bookmark list. */
|
||||
static int dir_load_from_json(bookmark_dir_t *dir, const char *json) {
|
||||
if (dir == NULL || json == NULL) return -1;
|
||||
|
||||
cJSON *arr = cJSON_Parse(json);
|
||||
if (arr == NULL) return -1;
|
||||
if (!cJSON_IsArray(arr)) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Free existing bookmarks. */
|
||||
free_dir_bookmarks(dir);
|
||||
|
||||
int count = cJSON_GetArraySize(arr);
|
||||
if (count > BOOKMARKS_MAX_PER_DIR) count = BOOKMARKS_MAX_PER_DIR;
|
||||
|
||||
dir->bookmarks = g_new0(bookmark_t, count);
|
||||
dir->count = 0;
|
||||
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, arr) {
|
||||
if (dir->count >= count) break;
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
cJSON *t2 = cJSON_GetArrayItem(tag, 2);
|
||||
cJSON *t3 = cJSON_GetArrayItem(tag, 3);
|
||||
|
||||
if (!cJSON_IsString(t0) || strcmp(t0->valuestring, "bookmark") != 0)
|
||||
continue;
|
||||
if (!cJSON_IsString(t1)) continue;
|
||||
|
||||
dir->bookmarks[dir->count].url = g_strdup(t1->valuestring);
|
||||
dir->bookmarks[dir->count].title = g_strdup(
|
||||
(t2 && cJSON_IsString(t2)) ? t2->valuestring : "");
|
||||
dir->bookmarks[dir->count].added =
|
||||
(t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0;
|
||||
dir->count++;
|
||||
}
|
||||
|
||||
cJSON_Delete(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── NIP-44 encryption / decryption ────────────────────────────────── */
|
||||
|
||||
/* Encrypt a plaintext string with NIP-44 (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *encrypt_content(const char *plaintext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
|
||||
char *ciphertext = NULL;
|
||||
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
|
||||
&ciphertext);
|
||||
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
|
||||
g_printerr("[bookmarks] NIP-44 encrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
/* Decrypt a NIP-44 ciphertext (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *decrypt_content(const char *ciphertext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
|
||||
|
||||
char *plaintext = NULL;
|
||||
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
|
||||
&plaintext);
|
||||
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
|
||||
g_printerr("[bookmarks] NIP-44 decrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/* ── Publish ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse bootstrap relays from settings into an array.
|
||||
* Returns the count. Fills urls_out (pointers into relay_buf).
|
||||
* Caller must g_free(relay_buf) after use. */
|
||||
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
*relay_buf = g_strdup(s->bootstrap_relays);
|
||||
int count = 0;
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && count < max) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
urls_out[count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Publish a kind 30003 event for a directory.
|
||||
* Encrypts the directory's bookmarks, signs, publishes to relays,
|
||||
* and stores in SQLite. Returns 0 on success, -1 on error. */
|
||||
static int publish_directory(const char *dir_name) {
|
||||
if (!g_have_signer) {
|
||||
g_printerr("[bookmarks] No signer, cannot publish\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int idx = find_dir(dir_name);
|
||||
if (idx < 0) {
|
||||
g_printerr("[bookmarks] Directory '%s' not found\n", dir_name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Serialize and encrypt. */
|
||||
char *json = dir_to_json(&g_dirs[idx]);
|
||||
if (json == NULL) return -1;
|
||||
|
||||
char *ciphertext = encrypt_content(json);
|
||||
g_free(json);
|
||||
if (ciphertext == NULL) return -1;
|
||||
|
||||
/* Build tags: [["d", dir_name]] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(dir_name));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
/* Create and sign the event. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
30003, ciphertext, tags, g_signer, time(NULL));
|
||||
g_free(ciphertext);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[bookmarks] Failed to create/sign event\n");
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[bookmarks] Published directory '%s' to %d/%d relays\n",
|
||||
dir_name, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[bookmarks] No relays configured, event stored locally only\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
|
||||
/* Load cached kind 30003 events from SQLite. */
|
||||
if (g_pubkey[0] != '\0') {
|
||||
/* We need to query all kind 30003 events for this pubkey.
|
||||
* db_get_events returns an array of events. */
|
||||
cJSON *events = db_get_events(g_pubkey, 30003, 0);
|
||||
if (events != NULL) {
|
||||
int n = cJSON_GetArraySize(events);
|
||||
g_print("[bookmarks] Loading %d cached directory event(s)\n", n);
|
||||
cJSON *event;
|
||||
cJSON_ArrayForEach(event, events) {
|
||||
/* Get the d tag to find the directory name. */
|
||||
const char *dir_name = "General";
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (cJSON_IsArray(tags)) {
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
if (cJSON_IsString(t0) &&
|
||||
strcmp(t0->valuestring, "d") == 0) {
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t1)) {
|
||||
dir_name = t1->valuestring;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Decrypt the content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event,
|
||||
"content");
|
||||
if (cJSON_IsString(content) && content->valuestring[0]) {
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext) {
|
||||
int idx = ensure_dir(dir_name);
|
||||
if (idx >= 0) {
|
||||
dir_load_from_json(&g_dirs[idx], plaintext);
|
||||
}
|
||||
free(plaintext);
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure "General" always exists. */
|
||||
ensure_dir("General");
|
||||
|
||||
g_print("[bookmarks] Initialized: %d directory/ies, signer=%s\n",
|
||||
g_dir_count, g_have_signer ? "yes" : "no");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void bookmarks_cleanup(void) {
|
||||
if (g_dirs) {
|
||||
for (int i = 0; i < g_dir_count; i++) {
|
||||
free_dir_bookmarks(&g_dirs[i]);
|
||||
}
|
||||
g_free(g_dirs);
|
||||
g_dirs = NULL;
|
||||
}
|
||||
g_dir_count = 0;
|
||||
g_dir_cap = 0;
|
||||
g_signer = NULL;
|
||||
g_have_signer = 0;
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out) {
|
||||
if (count_out) *count_out = g_dir_count;
|
||||
return g_dirs;
|
||||
}
|
||||
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name) {
|
||||
int idx = find_dir(name);
|
||||
return (idx >= 0) ? &g_dirs[idx] : NULL;
|
||||
}
|
||||
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title) {
|
||||
if (!g_have_signer) {
|
||||
g_printerr("[bookmarks] No signer, cannot add\n");
|
||||
return -1;
|
||||
}
|
||||
if (dir == NULL) dir = "General";
|
||||
if (url == NULL || url[0] == '\0') return -1;
|
||||
|
||||
int idx = ensure_dir(dir);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
/* Check if already exists. */
|
||||
if (find_bookmark_in_dir(&g_dirs[idx], url) >= 0) {
|
||||
g_print("[bookmarks] URL already bookmarked in '%s'\n", dir);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check capacity. */
|
||||
if (g_dirs[idx].count >= BOOKMARKS_MAX_PER_DIR) {
|
||||
g_printerr("[bookmarks] Directory '%s' is full\n", dir);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Add to in-memory list. */
|
||||
int n = g_dirs[idx].count;
|
||||
g_dirs[idx].bookmarks = g_realloc(g_dirs[idx].bookmarks,
|
||||
(n + 1) * sizeof(bookmark_t));
|
||||
g_dirs[idx].bookmarks[n].url = g_strdup(url);
|
||||
g_dirs[idx].bookmarks[n].title = g_strdup(title ? title : "");
|
||||
g_dirs[idx].bookmarks[n].added = (long)time(NULL);
|
||||
g_dirs[idx].count++;
|
||||
|
||||
g_print("[bookmarks] Added '%s' to '%s'\n", url, dir);
|
||||
|
||||
/* Publish. */
|
||||
return publish_directory(dir);
|
||||
}
|
||||
|
||||
int bookmarks_remove(const char *dir, const char *url) {
|
||||
if (!g_have_signer) return -1;
|
||||
if (dir == NULL) dir = "General";
|
||||
|
||||
int idx = find_dir(dir);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
int bidx = find_bookmark_in_dir(&g_dirs[idx], url);
|
||||
if (bidx < 0) return -1;
|
||||
|
||||
/* Free the bookmark. */
|
||||
g_free(g_dirs[idx].bookmarks[bidx].url);
|
||||
g_free(g_dirs[idx].bookmarks[bidx].title);
|
||||
|
||||
/* Shift remaining down. */
|
||||
for (int i = bidx; i < g_dirs[idx].count - 1; i++) {
|
||||
g_dirs[idx].bookmarks[i] = g_dirs[idx].bookmarks[i + 1];
|
||||
}
|
||||
g_dirs[idx].count--;
|
||||
|
||||
g_print("[bookmarks] Removed '%s' from '%s'\n", url, dir);
|
||||
return publish_directory(dir);
|
||||
}
|
||||
|
||||
int bookmarks_move(const char *from_dir, const char *url, const char *to_dir) {
|
||||
if (!g_have_signer) return -1;
|
||||
if (from_dir == NULL) from_dir = "General";
|
||||
if (to_dir == NULL) to_dir = "General";
|
||||
|
||||
int from_idx = find_dir(from_dir);
|
||||
if (from_idx < 0) return -1;
|
||||
|
||||
int bidx = find_bookmark_in_dir(&g_dirs[from_idx], url);
|
||||
if (bidx < 0) return -1;
|
||||
|
||||
/* Save the bookmark data. */
|
||||
char *saved_url = g_strdup(g_dirs[from_idx].bookmarks[bidx].url);
|
||||
char *saved_title = g_strdup(g_dirs[from_idx].bookmarks[bidx].title);
|
||||
long saved_added = g_dirs[from_idx].bookmarks[bidx].added;
|
||||
|
||||
/* Remove from source. */
|
||||
g_free(g_dirs[from_idx].bookmarks[bidx].url);
|
||||
g_free(g_dirs[from_idx].bookmarks[bidx].title);
|
||||
for (int i = bidx; i < g_dirs[from_idx].count - 1; i++) {
|
||||
g_dirs[from_idx].bookmarks[i] = g_dirs[from_idx].bookmarks[i + 1];
|
||||
}
|
||||
g_dirs[from_idx].count--;
|
||||
|
||||
/* Add to destination. */
|
||||
int to_idx = ensure_dir(to_dir);
|
||||
if (to_idx < 0) {
|
||||
g_free(saved_url);
|
||||
g_free(saved_title);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = g_dirs[to_idx].count;
|
||||
g_dirs[to_idx].bookmarks = g_realloc(g_dirs[to_idx].bookmarks,
|
||||
(n + 1) * sizeof(bookmark_t));
|
||||
g_dirs[to_idx].bookmarks[n].url = saved_url;
|
||||
g_dirs[to_idx].bookmarks[n].title = saved_title;
|
||||
g_dirs[to_idx].bookmarks[n].added = saved_added;
|
||||
g_dirs[to_idx].count++;
|
||||
|
||||
g_print("[bookmarks] Moved '%s' from '%s' to '%s'\n",
|
||||
url, from_dir, to_dir);
|
||||
|
||||
/* Publish both directories. */
|
||||
publish_directory(from_dir);
|
||||
return publish_directory(to_dir);
|
||||
}
|
||||
|
||||
int bookmarks_create_dir(const char *name) {
|
||||
if (!g_have_signer) return -1;
|
||||
if (name == NULL || name[0] == '\0') return -1;
|
||||
if (find_dir(name) >= 0) {
|
||||
g_printerr("[bookmarks] Directory '%s' already exists\n", name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int idx = ensure_dir(name);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
g_print("[bookmarks] Created directory '%s'\n", name);
|
||||
return publish_directory(name);
|
||||
}
|
||||
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general) {
|
||||
if (!g_have_signer) return -1;
|
||||
if (name == NULL) return -1;
|
||||
if (strcmp(name, "General") == 0) {
|
||||
g_printerr("[bookmarks] Cannot delete 'General'\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int idx = find_dir(name);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
/* Optionally move bookmarks to General. */
|
||||
if (move_to_general && g_dirs[idx].count > 0) {
|
||||
int gen_idx = ensure_dir("General");
|
||||
if (gen_idx >= 0) {
|
||||
for (int i = 0; i < g_dirs[idx].count; i++) {
|
||||
int n = g_dirs[gen_idx].count;
|
||||
g_dirs[gen_idx].bookmarks = g_realloc(
|
||||
g_dirs[gen_idx].bookmarks,
|
||||
(n + 1) * sizeof(bookmark_t));
|
||||
g_dirs[gen_idx].bookmarks[n] = g_dirs[idx].bookmarks[i];
|
||||
g_dirs[gen_idx].count++;
|
||||
}
|
||||
g_dirs[idx].count = 0; /* Don't free — ownership moved */
|
||||
g_dirs[idx].bookmarks = NULL;
|
||||
publish_directory("General");
|
||||
}
|
||||
}
|
||||
|
||||
/* Free the directory's data. */
|
||||
free_dir_bookmarks(&g_dirs[idx]);
|
||||
|
||||
/* Remove from the array. */
|
||||
for (int i = idx; i < g_dir_count - 1; i++) {
|
||||
g_dirs[i] = g_dirs[i + 1];
|
||||
}
|
||||
g_dir_count--;
|
||||
|
||||
g_print("[bookmarks] Deleted directory '%s'\n", name);
|
||||
|
||||
/* TODO: publish a kind 5 deletion event for the old d-tag.
|
||||
* For now, the directory event will remain on relays but won't
|
||||
* appear in the UI. A future fetch will re-add it — we need the
|
||||
* deletion event to prevent that. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json) {
|
||||
if (dir_name == NULL || json == NULL) return -1;
|
||||
int idx = ensure_dir(dir_name);
|
||||
if (idx < 0) return -1;
|
||||
return dir_load_from_json(&g_dirs[idx], json);
|
||||
}
|
||||
|
||||
int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
const cJSON *event = (const cJSON *)event_cjson;
|
||||
if (event == NULL) return -1;
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* If we have a signer, decrypt and load into memory. */
|
||||
if (!g_have_signer) return 0;
|
||||
|
||||
/* Get the d tag. */
|
||||
const char *dir_name = "General";
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (cJSON_IsArray(tags)) {
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0) {
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t1)) dir_name = t1->valuestring;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Decrypt content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (cJSON_IsString(content) && content->valuestring[0]) {
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext) {
|
||||
int idx = ensure_dir(dir_name);
|
||||
if (idx >= 0) {
|
||||
dir_load_from_json(&g_dirs[idx], plaintext);
|
||||
}
|
||||
free(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
|
||||
*
|
||||
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
|
||||
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
|
||||
* and stored in the event's content field as a JSON tag array.
|
||||
*
|
||||
* The bookmarks sync across all devices the user logs in on via the
|
||||
* bootstrap relays.
|
||||
*/
|
||||
|
||||
#ifndef BOOKMARKS_H
|
||||
#define BOOKMARKS_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <time.h>
|
||||
|
||||
/* nostr_signer_t is needed for the init function */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define BOOKMARKS_MAX_PER_DIR 500
|
||||
#define BOOKMARKS_DIR_NAME_MAX 128
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
char *title;
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_t;
|
||||
|
||||
/*
|
||||
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
|
||||
* database and decrypts them using the signer.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars)
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark list. Call at shutdown. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* ── Read access ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Get the list of directories (read-only). Returns a pointer to the
|
||||
* internal array (valid until the next bookmarks operation). */
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
|
||||
|
||||
/* Get a specific directory by name. Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
|
||||
/* ── Write access (requires signer) ────────────────────────────────── */
|
||||
|
||||
/* Add a bookmark to a directory. If the directory doesn't exist, it's
|
||||
* created. Updates the in-memory list, re-encrypts, publishes a new
|
||||
* kind 30003 event for that directory, and stores in SQLite.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_dir, const char *url,
|
||||
const char *to_dir);
|
||||
|
||||
/* Create a new empty directory. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if directory already exists / error. */
|
||||
int bookmarks_create_dir(const char *name);
|
||||
|
||||
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
|
||||
* to "General" before deletion. Publishes a kind 5 deletion event.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general);
|
||||
|
||||
/* ── Internal (used by relay fetch) ────────────────────────────────── */
|
||||
|
||||
/* Load bookmarks for a directory from a decrypted kind 30003 event's
|
||||
* content JSON. Called by bookmarks_init and after relay fetch. */
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
|
||||
|
||||
/* Store a raw kind 30003 event in the database and decrypt its content
|
||||
* into the in-memory list. Called by the relay fetch after login. */
|
||||
int bookmarks_store_and_load_event(const void *event_cjson);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* BOOKMARKS_H */
|
||||
@@ -0,0 +1,474 @@
|
||||
/*
|
||||
* cli.c — command-line flag parsing for sovereign_browser
|
||||
*
|
||||
* Uses getopt_long to parse flags before gtk_init(). Recognized flags
|
||||
* are stripped from argv (getopt_long reorders argv in place; optind
|
||||
* points at the first positional arg on return). We collect positional
|
||||
* args as startup URLs.
|
||||
*
|
||||
* Login flags build a cJSON params object matching the shape documented
|
||||
* in agent_login.h and call agent_login(), reusing the same code path
|
||||
* as the MCP 'login' tool.
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L /* strdup, getopt_long */
|
||||
|
||||
#include "cli.h"
|
||||
#include "version.h"
|
||||
#include "agent_login.h"
|
||||
#include "key_store.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <getopt.h>
|
||||
|
||||
/* ── Usage text ────────────────────────────────────────────────────── */
|
||||
|
||||
void cli_print_usage(FILE *fp) {
|
||||
fprintf(fp,
|
||||
"sovereign_browser %s — WebKitGTK + C99\n"
|
||||
"\n"
|
||||
"Usage: sovereign_browser [OPTIONS] [URL...]\n"
|
||||
"\n"
|
||||
"Positional URLs are opened in tabs at startup (unless a session is\n"
|
||||
"restored). --url flags are equivalent and may be repeated.\n"
|
||||
"\n"
|
||||
"Browser / Startup:\n"
|
||||
" --url <url> Open URL in a tab (repeatable)\n"
|
||||
" --new-tab-url <url> Override the default new-tab URL\n"
|
||||
" --no-session-restore Skip session restore for this run\n"
|
||||
" --session-restore Force session restore even if disabled\n"
|
||||
" --max-tabs <n> Override max simultaneous tabs\n"
|
||||
" -V, --version Print version and exit\n"
|
||||
" -h, --help Show this help and exit\n"
|
||||
"\n"
|
||||
"Agent Server:\n"
|
||||
" --port <port> Override agent MCP server port\n"
|
||||
" --no-agent Disable the agent server for this run\n"
|
||||
" --agent Force-enable the agent server\n"
|
||||
" --agent-origin <origin> Allowed origin (repeatable)\n"
|
||||
"\n"
|
||||
"Login (skips the GTK login dialog):\n"
|
||||
" --no-login Browse without a Nostr identity (normal browser mode)\n"
|
||||
" --login-method <m> Method: generate|local|seed|readonly|nip46|nsigner\n"
|
||||
" --nsec <nsec1...> (local) nsec bech32 private key\n"
|
||||
" --privkey <hex> (local) 64-char hex private key\n"
|
||||
" --mnemonic <words> (seed) BIP-39 mnemonic, quoted\n"
|
||||
" --account <n> (seed) BIP-44 account index (default 0)\n"
|
||||
" --npub <npub1...> (readonly) npub bech32 public key\n"
|
||||
" --pubkey <hex> (readonly) 64-char hex pubkey\n"
|
||||
" --bunker <url> (nip46) bunker:// URL\n"
|
||||
" --nsigner-transport <t> (nsigner) serial|unix|tcp|qrexec\n"
|
||||
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
|
||||
" --nsigner-service <name> (nsigner) qrexec service name\n"
|
||||
" --nsigner-index <n> (nsigner) key index (default 0)\n"
|
||||
" --login-timeout <ms> Override agent login timeout (GTK dialog path)\n"
|
||||
"\n"
|
||||
"Diagnostics:\n"
|
||||
" -v, --verbose Increase log verbosity (repeatable)\n"
|
||||
" -q, --quiet Suppress non-error log output\n"
|
||||
"\n"
|
||||
"Examples:\n"
|
||||
" sovereign_browser https://example.com\n"
|
||||
" sovereign_browser --login-method generate --url https://example.com\n"
|
||||
" sovereign_browser --login-method local --nsec nsec1... --port 18888\n"
|
||||
" sovereign_browser --login-method readonly --npub npub1... --no-agent\n"
|
||||
" sovereign_browser --no-agent --url https://a.com --url https://b.com\n",
|
||||
SB_VERSION);
|
||||
}
|
||||
|
||||
/* ── getopt_long option table ──────────────────────────────────────── */
|
||||
|
||||
enum {
|
||||
OPT_URL = 1000,
|
||||
OPT_NEW_TAB_URL,
|
||||
OPT_NO_SESSION_RESTORE,
|
||||
OPT_SESSION_RESTORE,
|
||||
OPT_MAX_TABS,
|
||||
OPT_VERSION,
|
||||
OPT_PORT,
|
||||
OPT_NO_AGENT,
|
||||
OPT_AGENT,
|
||||
OPT_AGENT_ORIGIN,
|
||||
OPT_LOGIN_METHOD,
|
||||
OPT_NSEC,
|
||||
OPT_PRIVKEY,
|
||||
OPT_MNEMONIC,
|
||||
OPT_ACCOUNT,
|
||||
OPT_NPUB,
|
||||
OPT_PUBKEY,
|
||||
OPT_BUNKER,
|
||||
OPT_NSIGNER_TRANSPORT,
|
||||
OPT_NSIGNER_DEVICE,
|
||||
OPT_NSIGNER_SERVICE,
|
||||
OPT_NSIGNER_INDEX,
|
||||
OPT_NO_LOGIN,
|
||||
OPT_LOGIN_TIMEOUT,
|
||||
OPT_VERBOSE,
|
||||
OPT_QUIET,
|
||||
OPT_HELP,
|
||||
};
|
||||
|
||||
static struct option long_opts[] = {
|
||||
/* Browser / startup */
|
||||
{"url", required_argument, 0, OPT_URL},
|
||||
{"new-tab-url", required_argument, 0, OPT_NEW_TAB_URL},
|
||||
{"no-session-restore", no_argument, 0, OPT_NO_SESSION_RESTORE},
|
||||
{"session-restore", no_argument, 0, OPT_SESSION_RESTORE},
|
||||
{"max-tabs", required_argument, 0, OPT_MAX_TABS},
|
||||
{"version", no_argument, 0, OPT_VERSION},
|
||||
/* Agent server */
|
||||
{"port", required_argument, 0, OPT_PORT},
|
||||
{"no-agent", no_argument, 0, OPT_NO_AGENT},
|
||||
{"agent", no_argument, 0, OPT_AGENT},
|
||||
{"agent-origin", required_argument, 0, OPT_AGENT_ORIGIN},
|
||||
/* Login */
|
||||
{"login-method", required_argument, 0, OPT_LOGIN_METHOD},
|
||||
{"nsec", required_argument, 0, OPT_NSEC},
|
||||
{"privkey", required_argument, 0, OPT_PRIVKEY},
|
||||
{"mnemonic", required_argument, 0, OPT_MNEMONIC},
|
||||
{"account", required_argument, 0, OPT_ACCOUNT},
|
||||
{"npub", required_argument, 0, OPT_NPUB},
|
||||
{"pubkey", required_argument, 0, OPT_PUBKEY},
|
||||
{"bunker", required_argument, 0, OPT_BUNKER},
|
||||
{"nsigner-transport", required_argument, 0, OPT_NSIGNER_TRANSPORT},
|
||||
{"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE},
|
||||
{"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE},
|
||||
{"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX},
|
||||
{"no-login", no_argument, 0, OPT_NO_LOGIN},
|
||||
{"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT},
|
||||
/* Diagnostics */
|
||||
{"verbose", no_argument, 0, OPT_VERBOSE},
|
||||
{"quiet", no_argument, 0, OPT_QUIET},
|
||||
{"help", no_argument, 0, OPT_HELP},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
static const char *short_opts = "vVhq";
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
static char *xstrdup(const char *s) {
|
||||
if (s == NULL) return NULL;
|
||||
char *p = strdup(s);
|
||||
if (p == NULL) {
|
||||
fprintf(stderr, "[cli] out of memory\n");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static void add_string(char ***arr, int *count, const char *value) {
|
||||
*arr = g_realloc(*arr, sizeof(char *) * (*count + 1));
|
||||
(*arr)[*count] = xstrdup(value);
|
||||
(*count)++;
|
||||
}
|
||||
|
||||
static int valid_login_method(const char *m) {
|
||||
return strcmp(m, "generate") == 0 ||
|
||||
strcmp(m, "local") == 0 ||
|
||||
strcmp(m, "seed") == 0 ||
|
||||
strcmp(m, "readonly") == 0 ||
|
||||
strcmp(m, "nip46") == 0 ||
|
||||
strcmp(m, "nsigner") == 0;
|
||||
}
|
||||
|
||||
/* ── cli_args_free ─────────────────────────────────────────────────── */
|
||||
|
||||
void cli_args_free(cli_args_t *args) {
|
||||
if (args == NULL) return;
|
||||
if (args->urls) {
|
||||
for (int i = 0; i < args->url_count; i++) g_free(args->urls[i]);
|
||||
g_free(args->urls);
|
||||
}
|
||||
g_free(args->new_tab_url);
|
||||
if (args->agent_origins) {
|
||||
for (int i = 0; i < args->agent_origin_count; i++) g_free(args->agent_origins[i]);
|
||||
g_free(args->agent_origins);
|
||||
}
|
||||
g_free(args->login_method);
|
||||
g_free(args->nsec);
|
||||
g_free(args->privkey);
|
||||
g_free(args->mnemonic);
|
||||
g_free(args->npub);
|
||||
g_free(args->pubkey);
|
||||
g_free(args->bunker);
|
||||
g_free(args->nsigner_transport);
|
||||
g_free(args->nsigner_device);
|
||||
g_free(args->nsigner_service);
|
||||
memset(args, 0, sizeof(*args));
|
||||
}
|
||||
|
||||
/* ── cli_parse ─────────────────────────────────────────────────────── */
|
||||
|
||||
int cli_parse(int *argc, char ***argv, cli_args_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->account = -1;
|
||||
out->nsigner_index = -1;
|
||||
|
||||
/* getopt_long reorders argv so non-options end up at the end. */
|
||||
opterr = 0; /* we print our own errors */
|
||||
optind = 1;
|
||||
|
||||
int c;
|
||||
int long_idx;
|
||||
while ((c = getopt_long(*argc, *argv, short_opts, long_opts, &long_idx)) != -1) {
|
||||
switch (c) {
|
||||
/* Short options */
|
||||
case 'v': out->verbose++; break;
|
||||
case 'V': out->want_version = TRUE; return 0;
|
||||
case 'h': out->want_help = TRUE; return 0;
|
||||
case 'q': out->quiet = TRUE; break;
|
||||
case '?':
|
||||
if (optopt) {
|
||||
fprintf(stderr, "[cli] unknown short option '-%c'\n", optopt);
|
||||
} else {
|
||||
fprintf(stderr, "[cli] unknown long option near '%s'\n",
|
||||
(*argv)[optind - 1] ? (*argv)[optind - 1] : "?");
|
||||
}
|
||||
cli_print_usage(stderr);
|
||||
return -1;
|
||||
|
||||
/* Browser / startup */
|
||||
case OPT_URL:
|
||||
add_string(&out->urls, &out->url_count, optarg);
|
||||
break;
|
||||
case OPT_NEW_TAB_URL:
|
||||
g_free(out->new_tab_url);
|
||||
out->new_tab_url = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NO_SESSION_RESTORE:
|
||||
out->session_restore = CLI_TRISTATE_FALSE;
|
||||
break;
|
||||
case OPT_SESSION_RESTORE:
|
||||
out->session_restore = CLI_TRISTATE_TRUE;
|
||||
break;
|
||||
case OPT_MAX_TABS:
|
||||
out->max_tabs = atoi(optarg);
|
||||
if (out->max_tabs < 1) {
|
||||
fprintf(stderr, "[cli] --max-tabs must be >= 1\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_VERSION:
|
||||
out->want_version = TRUE;
|
||||
return 0;
|
||||
|
||||
/* Agent server */
|
||||
case OPT_PORT:
|
||||
out->agent_port = atoi(optarg);
|
||||
if (out->agent_port < 1 || out->agent_port > 65535) {
|
||||
fprintf(stderr, "[cli] --port must be 1-65535\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NO_AGENT:
|
||||
out->agent_enabled = CLI_TRISTATE_FALSE;
|
||||
break;
|
||||
case OPT_AGENT:
|
||||
out->agent_enabled = CLI_TRISTATE_TRUE;
|
||||
break;
|
||||
case OPT_AGENT_ORIGIN:
|
||||
add_string(&out->agent_origins, &out->agent_origin_count, optarg);
|
||||
break;
|
||||
|
||||
/* Login */
|
||||
case OPT_LOGIN_METHOD:
|
||||
if (!valid_login_method(optarg)) {
|
||||
fprintf(stderr, "[cli] invalid --login-method '%s'\n", optarg);
|
||||
fprintf(stderr, "[cli] use: generate|local|seed|readonly|nip46|nsigner\n");
|
||||
return -1;
|
||||
}
|
||||
g_free(out->login_method);
|
||||
out->login_method = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSEC:
|
||||
g_free(out->nsec);
|
||||
out->nsec = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_PRIVKEY:
|
||||
g_free(out->privkey);
|
||||
out->privkey = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_MNEMONIC:
|
||||
g_free(out->mnemonic);
|
||||
out->mnemonic = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_ACCOUNT:
|
||||
out->account = atoi(optarg);
|
||||
if (out->account < 0) {
|
||||
fprintf(stderr, "[cli] --account must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NPUB:
|
||||
g_free(out->npub);
|
||||
out->npub = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_PUBKEY:
|
||||
g_free(out->pubkey);
|
||||
out->pubkey = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_BUNKER:
|
||||
g_free(out->bunker);
|
||||
out->bunker = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_TRANSPORT:
|
||||
g_free(out->nsigner_transport);
|
||||
out->nsigner_transport = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_DEVICE:
|
||||
g_free(out->nsigner_device);
|
||||
out->nsigner_device = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_SERVICE:
|
||||
g_free(out->nsigner_service);
|
||||
out->nsigner_service = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_INDEX:
|
||||
out->nsigner_index = atoi(optarg);
|
||||
if (out->nsigner_index < 0) {
|
||||
fprintf(stderr, "[cli] --nsigner-index must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NO_LOGIN:
|
||||
out->no_login = TRUE;
|
||||
break;
|
||||
case OPT_LOGIN_TIMEOUT:
|
||||
out->login_timeout_ms = atoi(optarg);
|
||||
if (out->login_timeout_ms < 0) {
|
||||
fprintf(stderr, "[cli] --login-timeout must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
|
||||
/* Diagnostics */
|
||||
case OPT_VERBOSE:
|
||||
out->verbose++;
|
||||
break;
|
||||
case OPT_QUIET:
|
||||
out->quiet = TRUE;
|
||||
break;
|
||||
case OPT_HELP:
|
||||
out->want_help = TRUE;
|
||||
return 0;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[cli] unhandled option\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validate login-method-specific args. */
|
||||
if (out->login_method) {
|
||||
const char *m = out->login_method;
|
||||
if (strcmp(m, "local") == 0) {
|
||||
if (!out->nsec && !out->privkey) {
|
||||
fprintf(stderr, "[cli] --login-method local requires --nsec or --privkey\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "seed") == 0) {
|
||||
if (!out->mnemonic) {
|
||||
fprintf(stderr, "[cli] --login-method seed requires --mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "readonly") == 0) {
|
||||
if (!out->npub && !out->pubkey) {
|
||||
fprintf(stderr, "[cli] --login-method readonly requires --npub or --pubkey\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "nip46") == 0) {
|
||||
if (!out->bunker) {
|
||||
fprintf(stderr, "[cli] --login-method nip46 requires --bunker\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "nsigner") == 0) {
|
||||
if (!out->nsigner_transport || !out->nsigner_device) {
|
||||
fprintf(stderr, "[cli] --login-method nsigner requires --nsigner-transport and --nsigner-device\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
/* "generate" needs no extra args. */
|
||||
}
|
||||
|
||||
/* Collect positional args (after getopt_long reordering) as URLs. */
|
||||
for (int i = optind; i < *argc; i++) {
|
||||
add_string(&out->urls, &out->url_count, (*argv)[i]);
|
||||
}
|
||||
|
||||
/* Repack argv: keep argv[0], then positional args only. gtk_init()
|
||||
* will see a clean vector with no unknown options. */
|
||||
int new_argc = 1; /* argv[0] */
|
||||
for (int i = optind; i < *argc; i++) {
|
||||
(*argv)[new_argc++] = (*argv)[i];
|
||||
}
|
||||
(*argv)[new_argc] = NULL;
|
||||
*argc = new_argc;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── cli_login ─────────────────────────────────────────────────────── */
|
||||
|
||||
int cli_login(const cli_args_t *args) {
|
||||
if (args->login_method == NULL) {
|
||||
fprintf(stderr, "[cli] cli_login called with no --login-method\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Build the cJSON params object matching agent_login.h's contract. */
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(params, "method", args->login_method);
|
||||
|
||||
if (args->nsec) cJSON_AddStringToObject(params, "nsec", args->nsec);
|
||||
if (args->privkey) cJSON_AddStringToObject(params, "privkey_hex", args->privkey);
|
||||
if (args->mnemonic) cJSON_AddStringToObject(params, "mnemonic", args->mnemonic);
|
||||
if (args->account >= 0) cJSON_AddNumberToObject(params, "account", args->account);
|
||||
if (args->npub) cJSON_AddStringToObject(params, "npub", args->npub);
|
||||
if (args->pubkey) cJSON_AddStringToObject(params, "pubkey_hex", args->pubkey);
|
||||
if (args->bunker) cJSON_AddStringToObject(params, "bunker_url", args->bunker);
|
||||
if (args->nsigner_transport) cJSON_AddStringToObject(params, "transport", args->nsigner_transport);
|
||||
if (args->nsigner_device) cJSON_AddStringToObject(params, "device", args->nsigner_device);
|
||||
if (args->nsigner_service) cJSON_AddStringToObject(params, "service", args->nsigner_service);
|
||||
if (args->nsigner_index >= 0) cJSON_AddNumberToObject(params, "index", args->nsigner_index);
|
||||
|
||||
/* agent_login() calls app_set_signer() on success, which sets the
|
||||
* same global state the GTK dialog would. It also handles
|
||||
* nostr_init() internally. */
|
||||
cJSON *result = agent_login(params);
|
||||
cJSON_Delete(params);
|
||||
|
||||
if (result == NULL) {
|
||||
fprintf(stderr, "[cli] login returned NULL\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *success = cJSON_GetObjectItem(result, "success");
|
||||
if (!success || !cJSON_IsTrue(success)) {
|
||||
cJSON *err = cJSON_GetObjectItem(result, "error");
|
||||
const char *code = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "code")) : "?";
|
||||
const char *msg = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "message")) : "unknown";
|
||||
fprintf(stderr, "[cli] login failed: %s: %s\n", code ? code : "?", msg ? msg : "?");
|
||||
cJSON_Delete(result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Login succeeded. Print the pubkey for visibility. */
|
||||
cJSON *data = cJSON_GetObjectItem(result, "data");
|
||||
if (data) {
|
||||
const char *pubkey = cJSON_GetStringValue(cJSON_GetObjectItem(data, "pubkey"));
|
||||
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(data, "method"));
|
||||
g_print("[cli] Logged in: method=%s pubkey=%s\n",
|
||||
method ? method : "?", pubkey ? pubkey : "?");
|
||||
}
|
||||
cJSON_Delete(result);
|
||||
/* Private keys are never persisted to disk — they live only in RAM
|
||||
* for the duration of the session. The identity is held in the
|
||||
* global app_state_t (main.c) and is gone when the browser quits. */
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* cli.h — command-line flag parsing for sovereign_browser
|
||||
*
|
||||
* Parses argv before gtk_init() so GTK doesn't abort on our flags.
|
||||
* Recognized flags are stripped from argv/argc so gtk_init() only sees
|
||||
* positional URLs. CLI flags override settings.conf for this run only
|
||||
* (nothing is written to disk).
|
||||
*
|
||||
* Login flags build a cJSON params object and call agent_login(),
|
||||
* reusing the same code path as the MCP 'login' tool. On success the
|
||||
* GTK login dialog is skipped entirely.
|
||||
*/
|
||||
|
||||
#ifndef CLI_H
|
||||
#define CLI_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h> /* FILE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Tri-state for boolean overrides. */
|
||||
typedef enum {
|
||||
CLI_TRISTATE_UNSET = 0,
|
||||
CLI_TRISTATE_TRUE,
|
||||
CLI_TRISTATE_FALSE
|
||||
} cli_tristate_t;
|
||||
|
||||
/* Parsed command-line arguments. Zero-initialized by cli_parse().
|
||||
* Strings are heap-allocated and owned by the cli_args_t — free with
|
||||
* cli_args_free(). */
|
||||
typedef struct {
|
||||
/* Startup URLs: --url flags + positional args, in order. */
|
||||
char **urls;
|
||||
int url_count;
|
||||
|
||||
/* Browser overrides (NULL / 0 = not set). */
|
||||
char *new_tab_url; /* --new-tab-url */
|
||||
int max_tabs; /* --max-tabs (0 = unset) */
|
||||
cli_tristate_t session_restore; /* --session-restore / --no-session-restore */
|
||||
|
||||
/* Agent server overrides. */
|
||||
int agent_port; /* --port (0 = unset) */
|
||||
cli_tristate_t agent_enabled; /* --agent / --no-agent */
|
||||
char **agent_origins; /* --agent-origin (repeatable) */
|
||||
int agent_origin_count;
|
||||
|
||||
/* Login. login_method == NULL means "show GTK dialog".
|
||||
* no_login == TRUE means skip login entirely (browse without identity). */
|
||||
gboolean no_login; /* --no-login */
|
||||
char *login_method; /* --login-method */
|
||||
char *nsec; /* --nsec */
|
||||
char *privkey; /* --privkey */
|
||||
char *mnemonic; /* --mnemonic */
|
||||
int account; /* --account (-1 = unset, defaults to 0) */
|
||||
char *npub; /* --npub */
|
||||
char *pubkey; /* --pubkey */
|
||||
char *bunker; /* --bunker */
|
||||
char *nsigner_transport; /* --nsigner-transport */
|
||||
char *nsigner_device; /* --nsigner-device */
|
||||
char *nsigner_service; /* --nsigner-service */
|
||||
int nsigner_index; /* --nsigner-index (-1 = unset) */
|
||||
int login_timeout_ms; /* --login-timeout (0 = unset) */
|
||||
|
||||
/* Diagnostics. */
|
||||
int verbose; /* --verbose / -v (repeatable count) */
|
||||
gboolean quiet; /* --quiet / -q */
|
||||
|
||||
/* Exit-request flags set by --help / --version. */
|
||||
gboolean want_help;
|
||||
gboolean want_version;
|
||||
} cli_args_t;
|
||||
|
||||
/*
|
||||
* Parse command-line arguments.
|
||||
*
|
||||
* On entry, *argc / *argv are the raw values from main(). On return,
|
||||
* recognized flags are removed from *argv / *argc (repacked via
|
||||
* getopt_long's optind) so the remaining vector is safe to pass to
|
||||
* gtk_init(). Positional URL arguments are preserved.
|
||||
*
|
||||
* Returns:
|
||||
* 0 — parse OK (or --help / --version requested; check args.want_*).
|
||||
* -1 — parse error (message printed to stderr).
|
||||
*
|
||||
* On error or when want_help/want_version is true, the caller should
|
||||
* exit without calling gtk_init().
|
||||
*/
|
||||
int cli_parse(int *argc, char ***argv, cli_args_t *out);
|
||||
|
||||
/*
|
||||
* Free heap-allocated fields in a cli_args_t. Safe to call on a
|
||||
* zero-initialized struct.
|
||||
*/
|
||||
void cli_args_free(cli_args_t *args);
|
||||
|
||||
/*
|
||||
* Print usage text to the given stream.
|
||||
*/
|
||||
void cli_print_usage(FILE *fp);
|
||||
|
||||
/*
|
||||
* Perform CLI login by building a cJSON params object from args and
|
||||
* calling agent_login(). Requires nostr_init() to have been called
|
||||
* already (agent_login() handles re-init internally).
|
||||
*
|
||||
* Returns:
|
||||
* 0 — login succeeded (global app state is now set).
|
||||
* -1 — login failed (error message printed to stderr).
|
||||
*
|
||||
* If args->no_save_identity is FALSE, the identity is persisted to
|
||||
* ~/.sovereign_browser/identity.json via key_store_save() on success.
|
||||
*/
|
||||
int cli_login(const cli_args_t *args);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CLI_H */
|
||||
@@ -0,0 +1,764 @@
|
||||
/*
|
||||
* 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 helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
/* ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
|
||||
static int sb_home_dir(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ~/.sovereign_browser/browser.db (legacy default per-user path). */
|
||||
static int db_path(char *out, size_t out_sz) {
|
||||
char dir[512];
|
||||
if (sb_home_dir(dir, sizeof(dir)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/browser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ~/.sovereign_browser/global.db (global settings + shortcuts). */
|
||||
static int db_global_path(char *out, size_t out_sz) {
|
||||
char dir[512];
|
||||
if (sb_home_dir(dir, sizeof(dir)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/global.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Schema ────────────────────────────────────────────────────────── */
|
||||
|
||||
static const char *SCHEMA_SQL =
|
||||
"CREATE TABLE IF NOT EXISTS events ("
|
||||
" id TEXT PRIMARY KEY,"
|
||||
" pubkey TEXT NOT NULL,"
|
||||
" kind INTEGER NOT NULL,"
|
||||
" created_at INTEGER NOT NULL,"
|
||||
" content TEXT,"
|
||||
" sig TEXT,"
|
||||
" raw_json TEXT NOT NULL,"
|
||||
" fetched_at INTEGER NOT NULL"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS event_tags ("
|
||||
" event_id TEXT NOT NULL,"
|
||||
" tag_name TEXT NOT NULL,"
|
||||
" tag_value TEXT,"
|
||||
" position INTEGER NOT NULL,"
|
||||
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS key_value ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS history ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT NOT NULL UNIQUE,"
|
||||
" title TEXT,"
|
||||
" visited_at INTEGER NOT NULL,"
|
||||
" visit_count INTEGER DEFAULT 1"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visited_at "
|
||||
" ON history(visited_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_url "
|
||||
" ON history(url);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_title "
|
||||
" ON history(title);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visit_count "
|
||||
" ON history(visit_count DESC);"
|
||||
"CREATE TABLE IF NOT EXISTS session ("
|
||||
" tab_index INTEGER PRIMARY KEY,"
|
||||
" url TEXT NOT NULL,"
|
||||
" title TEXT"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
|
||||
" ON events(pubkey, kind, created_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
|
||||
" ON event_tags(tag_name, tag_value);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
|
||||
" ON event_tags(event_id);";
|
||||
|
||||
/* ── Init / Close ──────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Open a SQLite database at the given path with the standard schema.
|
||||
* Closes any currently-open database first. Returns 0 on success.
|
||||
*/
|
||||
int db_init_with_path(const char *path) {
|
||||
if (path == NULL || path[0] == '\0') {
|
||||
g_printerr("[db] db_init_with_path: NULL path\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Close any currently-open database so we can switch databases. */
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
}
|
||||
|
||||
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': %s\n",
|
||||
path, g_db ? sqlite3_errmsg(g_db) : "(null)");
|
||||
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 (all tables use IF NOT EXISTS, so this is safe for
|
||||
* both global.db and per-user browser.db). */
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Open the global database at ~/.sovereign_browser/global.db.
|
||||
* Used at startup for global settings + shortcuts, before login.
|
||||
*/
|
||||
int db_init_global(void) {
|
||||
char path[512];
|
||||
if (db_global_path(path, sizeof(path)) != 0) {
|
||||
g_printerr("[db] Failed to get global database path\n");
|
||||
return -1;
|
||||
}
|
||||
return db_init_with_path(path);
|
||||
}
|
||||
|
||||
/*
|
||||
* Open the default per-user database at ~/.sovereign_browser/browser.db.
|
||||
* Kept for compatibility; the normal flow now uses db_init_with_path()
|
||||
* with a profile-specific path after login.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
return db_init_with_path(path);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Write a key-value pair to a specific database file, opening a separate
|
||||
* short-lived connection. This is used to save global settings to
|
||||
* global.db while the per-user browser.db is the main open database.
|
||||
* Creates the key_value table if it doesn't exist (safe for a fresh
|
||||
* global.db). Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set_to_file(const char *path, const char *key, const char *value) {
|
||||
if (path == NULL || key == NULL || value == NULL) return -1;
|
||||
|
||||
sqlite3 *db = NULL;
|
||||
int rc = sqlite3_open_v2(path, &db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
|
||||
SQLITE_OPEN_FULLMUTEX,
|
||||
NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: open '%s' failed: %s\n",
|
||||
path, db ? sqlite3_errmsg(db) : "(null)");
|
||||
if (db) sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_busy_timeout(db, 5000);
|
||||
|
||||
/* Ensure the key_value table exists (harmless if already present). */
|
||||
char *err = NULL;
|
||||
rc = sqlite3_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS key_value ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: create table failed: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(db,
|
||||
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: prepare failed: %s\n",
|
||||
sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
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);
|
||||
sqlite3_close(db);
|
||||
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
const char *db_kv_get(const char *key) {
|
||||
if (g_db == NULL || key == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT value FROM key_value WHERE key = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return NULL;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
|
||||
const char *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *val = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (val) {
|
||||
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
|
||||
result = g_kv_buf;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *db_kv_get_copy(const char *key) {
|
||||
const char *val = db_kv_get(key);
|
||||
if (val == NULL) return NULL;
|
||||
return g_strdup(val);
|
||||
}
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_add(const char *url, const char *title) {
|
||||
if (g_db == NULL || url == NULL || url[0] == '\0') return -1;
|
||||
|
||||
/* UPSERT: insert or update visited_at + visit_count. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO history (url, title, visited_at, visit_count) "
|
||||
"VALUES (?, ?, strftime('%s','now'), 1) "
|
||||
"ON CONFLICT(url) DO UPDATE SET "
|
||||
" title = excluded.title, "
|
||||
" visited_at = strftime('%s','now'), "
|
||||
" visit_count = visit_count + 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
if (limit <= 0) limit = 50;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"ORDER BY visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_int(stmt, 1, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_history_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── History search ────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || query == NULL || urls_out == NULL || count_out == NULL)
|
||||
return -1;
|
||||
if (limit <= 0) limit = 10;
|
||||
|
||||
/* Build a LIKE pattern: %query% (case-insensitive via COLLATE NOCASE
|
||||
* is not needed — SQLite LIKE is case-insensitive for ASCII by default). */
|
||||
char *pattern = g_strdup_printf("%%%s%%", query);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"WHERE url LIKE ? OR title LIKE ? "
|
||||
"ORDER BY visit_count DESC, visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_free(pattern);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pattern, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern, -1, NULL); /* same pattern, freed once */
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Re-bind (sqlite3_reset keeps bindings, but reset clears them in some
|
||||
* versions — re-bind to be safe). */
|
||||
char *pattern2 = g_strdup_printf("%%%s%%", query);
|
||||
sqlite3_bind_text(stmt, 1, pattern2, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern2, -1, NULL);
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_session_save(const char **urls, const char **titles, int count) {
|
||||
if (g_db == NULL || urls == NULL) return -1;
|
||||
|
||||
/* Clear the session table first. */
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Insert each tab. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (urls[i] == NULL) continue;
|
||||
sqlite3_bind_int(stmt, 1, i);
|
||||
sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3,
|
||||
(titles && titles[i]) ? titles[i] : "",
|
||||
-1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_reset(stmt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
|
||||
/* Count rows. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM session ORDER BY tab_index;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
if (count == 0) {
|
||||
sqlite3_finalize(stmt);
|
||||
*count_out = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
*count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_session_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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.
|
||||
* Kept for compatibility; prefer db_init_with_path() for per-user dbs.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init(void);
|
||||
|
||||
/*
|
||||
* Initialize the global database at ~/.sovereign_browser/global.db.
|
||||
* Used at startup for global settings + shortcuts, before login.
|
||||
* Creates tables and indexes if they don't exist.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init_global(void);
|
||||
|
||||
/*
|
||||
* Open a SQLite database at the given path with the standard schema.
|
||||
* Closes any currently-open database first (so you can switch from
|
||||
* global.db to a per-user browser.db after login).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init_with_path(const char *path);
|
||||
|
||||
/*
|
||||
* Close the database. Call at shutdown, or before switching to a
|
||||
* different database via db_init_with_path().
|
||||
*/
|
||||
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) in the currently-open database.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set(const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Set a key-value pair in a specific database file, opening a separate
|
||||
* short-lived connection. Used to save global settings to global.db
|
||||
* while the per-user browser.db is the main open database. Creates the
|
||||
* key_value table if it doesn't exist.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set_to_file(const char *path, const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Get a value by key.
|
||||
* Returns a pointer to the value string, or NULL if not found.
|
||||
* The pointer is valid until the next db_kv_get() call (uses a static
|
||||
* buffer). Call db_kv_get_copy() if you need a persistent copy.
|
||||
*/
|
||||
const char *db_kv_get(const char *key);
|
||||
|
||||
/*
|
||||
* Get a value by key, returning a newly allocated copy.
|
||||
* Caller must g_free() the result. Returns NULL if not found.
|
||||
*/
|
||||
char *db_kv_get_copy(const char *key);
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Add a URL to the history (UPSERT — increments visit_count and updates
|
||||
* visited_at if the URL already exists).
|
||||
* url — the URL to record
|
||||
* title — optional page title (NULL or "" for none)
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_add(const char *url, const char *title);
|
||||
|
||||
/*
|
||||
* Get recent history entries (most-recent-first).
|
||||
* limit — max number of entries (0 = default 50)
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/*
|
||||
* Clear all history entries.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_clear(void);
|
||||
|
||||
/*
|
||||
* Search history entries by URL or title substring.
|
||||
* query — substring to search for (case-insensitive)
|
||||
* limit — max number of entries (0 = default 10)
|
||||
*
|
||||
* Results are ranked by visit_count DESC, then visited_at DESC — so
|
||||
* frequently-visited and recently-visited sites appear first.
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Save the current session (open tab URLs) to the database.
|
||||
* Clears the session table first, then inserts the given URLs in order.
|
||||
* urls — array of URL strings
|
||||
* titles — array of title strings (can be NULL for no titles)
|
||||
* count — number of tabs
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_save(const char **urls, const char **titles, int count);
|
||||
|
||||
/*
|
||||
* Load the saved session from the database.
|
||||
* Fills urls_out and titles_out with parallel arrays (tab_index order).
|
||||
* Caller must free each string and the arrays.
|
||||
* Returns the number of tabs, or -1 on error / no session.
|
||||
*/
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
|
||||
/*
|
||||
* Clear the saved session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_clear(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DB_H */
|
||||
+87
-136
@@ -1,160 +1,111 @@
|
||||
/*
|
||||
* history.c — URL history tracking for sovereign_browser
|
||||
*
|
||||
* History is stored in the SQLite database (history table) with timestamps
|
||||
* and visit counts. No flat file, no entry cap.
|
||||
*/
|
||||
|
||||
#include "history.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── In-memory history ─────────────────────────────────────────── */
|
||||
|
||||
static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN];
|
||||
static int g_history_count = 0;
|
||||
|
||||
/* ── Path helper ───────────────────────────────────────────────── */
|
||||
|
||||
static int history_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/history.txt", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Persistence ───────────────────────────────────────────────── */
|
||||
|
||||
void history_load(void) {
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_history_count = 0;
|
||||
char line[HISTORY_URL_MAX_LEN];
|
||||
while (g_history_count < HISTORY_MAX_ENTRIES &&
|
||||
fgets(line, sizeof(line), f) != NULL) {
|
||||
/* Strip newline. */
|
||||
size_t len = strlen(line);
|
||||
if (len > 0 && line[len - 1] == '\n') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line);
|
||||
g_history_count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void history_save(void) {
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
/* Write in reverse order (most recent first) — same as in-memory order. */
|
||||
for (int i = 0; i < g_history_count; i++) {
|
||||
fprintf(f, "%s\n", g_history[i]);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* ── API ───────────────────────────────────────────────────────── */
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
void history_add(const char *url) {
|
||||
if (url == NULL || url[0] == '\0') {
|
||||
history_add_titled(url, NULL);
|
||||
}
|
||||
|
||||
void history_add_titled(const char *url, const char *title) {
|
||||
if (url == NULL || url[0] == '\0') return;
|
||||
|
||||
/* Skip sovereign:// API endpoints (not pages the user would want
|
||||
* in their recents). The actual pages — sovereign://settings,
|
||||
* sovereign://profile, sovereign://bookmarks, sovereign://security —
|
||||
* are kept in history so the user can quickly return to them. */
|
||||
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/add", 25) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://qr", 14) == 0 ||
|
||||
strncmp(url, "sovereign://nostr/", 18) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Skip sovereign:// internal pages. */
|
||||
if (strncmp(url, "sovereign://", 12) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Check if the URL is already in the history. */
|
||||
int found_idx = -1;
|
||||
for (int i = 0; i < g_history_count; i++) {
|
||||
if (strcmp(g_history[i], url) == 0) {
|
||||
found_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found_idx >= 0) {
|
||||
/* URL already exists — move it to the top. */
|
||||
if (found_idx == 0) {
|
||||
return; /* already at the top, nothing to do */
|
||||
}
|
||||
/* Shift items 0..found_idx-1 down by one. */
|
||||
char tmp[HISTORY_URL_MAX_LEN];
|
||||
snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]);
|
||||
for (int i = found_idx; i > 0; i--) {
|
||||
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
|
||||
}
|
||||
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp);
|
||||
} else {
|
||||
/* New URL — shift everything down by one to make room at the top. */
|
||||
if (g_history_count >= HISTORY_MAX_ENTRIES) {
|
||||
g_history_count = HISTORY_MAX_ENTRIES - 1;
|
||||
}
|
||||
for (int i = g_history_count; i > 0; i--) {
|
||||
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
|
||||
}
|
||||
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url);
|
||||
g_history_count++;
|
||||
}
|
||||
|
||||
history_save();
|
||||
db_history_add(url, title);
|
||||
}
|
||||
|
||||
int history_count(void) {
|
||||
return g_history_count;
|
||||
/* Query the count from the database via db_history_get.
|
||||
* For the Recents submenu we only show 20, but we need the
|
||||
* total count to decide whether to show the submenu. */
|
||||
char **urls = NULL;
|
||||
int count = 0;
|
||||
int rc = db_history_get(&urls, NULL, &count, 10000);
|
||||
if (rc < 0) return 0;
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const char *history_get(int index) {
|
||||
if (index < 0 || index >= g_history_count) {
|
||||
char *history_get(int index) {
|
||||
if (index < 0) return NULL;
|
||||
|
||||
char **urls = NULL;
|
||||
int count = 0;
|
||||
db_history_get(&urls, NULL, &count, index + 1);
|
||||
if (urls == NULL || count <= index) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
return g_history[index];
|
||||
|
||||
char *result = urls[index];
|
||||
/* Free the other entries. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i != index) g_free(urls[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *history_get_title(int index) {
|
||||
if (index < 0) return NULL;
|
||||
|
||||
char **urls = NULL;
|
||||
char **titles = NULL;
|
||||
int count = 0;
|
||||
db_history_get(&urls, &titles, &count, index + 1);
|
||||
if (urls == NULL || count <= index) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
if (titles) {
|
||||
for (int i = 0; i < count; i++) g_free(titles[i]);
|
||||
g_free(titles);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *result = titles[index];
|
||||
/* Free everything except the result. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i != index) g_free(titles[i]);
|
||||
g_free(urls[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
return result;
|
||||
}
|
||||
|
||||
void history_clear(void) {
|
||||
g_history_count = 0;
|
||||
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) == 0) {
|
||||
unlink(path);
|
||||
}
|
||||
db_history_clear();
|
||||
g_print("[history] cleared\n");
|
||||
}
|
||||
|
||||
+17
-14
@@ -1,8 +1,9 @@
|
||||
/*
|
||||
* history.h — URL history tracking for sovereign_browser
|
||||
*
|
||||
* Keeps a list of recently visited URLs (most recent first) and
|
||||
* provides them for the History submenu.
|
||||
* History is stored in the SQLite database (history table) with timestamps
|
||||
* and visit counts. There is no entry cap — the database handles unlimited
|
||||
* entries. The Recents submenu shows the most recent 20.
|
||||
*/
|
||||
|
||||
#ifndef HISTORY_H
|
||||
@@ -14,35 +15,37 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define HISTORY_MAX_ENTRIES 50
|
||||
#define HISTORY_URL_MAX_LEN 2048
|
||||
|
||||
/*
|
||||
* Add a URL to the history. If the URL is already the most recent
|
||||
* entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES.
|
||||
* Also persists to ~/.sovereign_browser/history.txt.
|
||||
* Add a URL to the history. UPSERT — if the URL already exists,
|
||||
* increments visit_count and updates visited_at. The title is optional
|
||||
* (NULL or "" for no title).
|
||||
*/
|
||||
void history_add(const char *url);
|
||||
void history_add_titled(const char *url, const char *title);
|
||||
|
||||
/*
|
||||
* Get the number of history entries.
|
||||
* Get the number of history entries available (queries the database).
|
||||
* Returns the count, or -1 on error.
|
||||
*/
|
||||
int history_count(void);
|
||||
|
||||
/*
|
||||
* Get a history entry by index (0 = most recent).
|
||||
* Returns NULL if index is out of range.
|
||||
* Get a history URL by index (0 = most recent).
|
||||
* Returns a newly allocated string (caller must g_free), or NULL if
|
||||
* index is out of range.
|
||||
*/
|
||||
const char *history_get(int index);
|
||||
char *history_get(int index);
|
||||
|
||||
/*
|
||||
* Load history from ~/.sovereign_browser/history.txt.
|
||||
* Called once at startup.
|
||||
* Get the title for a history entry by index.
|
||||
* Returns a newly allocated string, or NULL if no title / out of range.
|
||||
*/
|
||||
void history_load(void);
|
||||
char *history_get_title(int index);
|
||||
|
||||
/*
|
||||
* Clear all history entries and delete the file.
|
||||
* Clear all history entries.
|
||||
*/
|
||||
void history_clear(void);
|
||||
|
||||
|
||||
+137
-192
@@ -1,209 +1,36 @@
|
||||
/*
|
||||
* key_store.c — Nostr identity persistence for sovereign_browser
|
||||
* key_store.c — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Private keys are NEVER persisted to disk. They live only in RAM for
|
||||
* the duration of the session and are gone when the browser quits.
|
||||
*
|
||||
* This module provides:
|
||||
* - key_store_create_signer() — create a nostr_signer_t from an
|
||||
* in-memory identity struct
|
||||
* - key_store_delete_legacy_file() — defensively delete any
|
||||
* identity.json left by a previous version that persisted keys
|
||||
* - key_store_save_profile_identity() — save public identity info
|
||||
* (method, pubkey_hex) to the per-profile identity.json
|
||||
* - key_store_load_profile_identity() — load public identity info
|
||||
* from the per-profile identity.json
|
||||
*/
|
||||
|
||||
#include "key_store.h"
|
||||
#include "profile.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "nostr_core/nip006.h"
|
||||
#include "nostr_core/nip019.h"
|
||||
|
||||
/* ── Path helpers ─────────────────────────────────────────────── */
|
||||
|
||||
int key_store_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Ensure ~/.sovereign_browser/ exists. */
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/identity.json", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Save / load ──────────────────────────────────────────────── */
|
||||
|
||||
static const char *method_to_string(key_store_method_t m) {
|
||||
switch (m) {
|
||||
case KEY_STORE_METHOD_LOCAL: return "local";
|
||||
case KEY_STORE_METHOD_SEED: return "seed";
|
||||
case KEY_STORE_METHOD_READONLY: return "readonly";
|
||||
case KEY_STORE_METHOD_NIP46: return "nip46";
|
||||
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
static key_store_method_t string_to_method(const char *s) {
|
||||
if (!s) return KEY_STORE_METHOD_NONE;
|
||||
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
|
||||
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
|
||||
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
|
||||
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
|
||||
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
|
||||
return KEY_STORE_METHOD_NONE;
|
||||
}
|
||||
|
||||
int key_store_save(const key_store_identity_t *identity) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Write with restrictive permissions. */
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
|
||||
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_LOCAL ||
|
||||
identity->method == KEY_STORE_METHOD_SEED) {
|
||||
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
|
||||
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
|
||||
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
|
||||
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
|
||||
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
|
||||
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
|
||||
}
|
||||
|
||||
fprintf(f, "\n}\n");
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
|
||||
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
/* Skip whitespace and colon. */
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
if (*p != '"') return -1;
|
||||
p++;
|
||||
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i < out_sz - 1) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
}
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
return (i > 0 || *p == '"') ? 0 : -1;
|
||||
}
|
||||
|
||||
static int json_extract_int(const char *json, const char *key, int *out) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
char *end;
|
||||
long val = strtol(p, &end, 10);
|
||||
if (end == p) return -1;
|
||||
*out = (int)val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_load(key_store_identity_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
if (errno == ENOENT) return 1; /* no identity file */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Read the whole file (small). */
|
||||
char buf[4096];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
char method_str[32];
|
||||
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
out->method = string_to_method(method_str);
|
||||
if (out->method == KEY_STORE_METHOD_NONE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
|
||||
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
|
||||
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
|
||||
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
|
||||
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
|
||||
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_clear(void) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Signer creation ──────────────────────────────────────────── */
|
||||
/* ── Signer creation ──────────────────────────────────────────────── */
|
||||
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
if (identity == NULL) {
|
||||
@@ -267,3 +94,121 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Legacy file cleanup ──────────────────────────────────────────── */
|
||||
|
||||
int key_store_delete_legacy_file(void) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json",
|
||||
home);
|
||||
if (n < 0 || (size_t)n >= sizeof(path)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete the file if it exists. ENOENT is not an error. */
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Per-profile identity persistence ──────────────────────────────── *
|
||||
* The per-profile identity.json stores public identity information
|
||||
* (method, pubkey_hex) so the browser can display profile info without
|
||||
* requiring the user to log in first. Private keys are NEVER stored.
|
||||
*/
|
||||
|
||||
int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t method) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
profile_get_identity_path(pubkey_hex, path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
|
||||
cJSON_AddNumberToObject(root, "method", (double)method);
|
||||
|
||||
/* Method name for human readability. */
|
||||
const char *method_name = "none";
|
||||
switch (method) {
|
||||
case KEY_STORE_METHOD_NONE: method_name = "none"; break;
|
||||
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;
|
||||
}
|
||||
cJSON_AddStringToObject(root, "method_name", method_name);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
if (json == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
fputs(json, f);
|
||||
fputc('\n', f);
|
||||
fclose(f);
|
||||
free(json);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_load_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t *method_out) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
profile_get_identity_path(pubkey_hex, path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char buf[1024];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = -1;
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
|
||||
if (cJSON_IsString(pk) && strcmp(pk->valuestring, pubkey_hex) == 0) {
|
||||
if (method_out) {
|
||||
cJSON *m = cJSON_GetObjectItemCaseSensitive(root, "method");
|
||||
if (cJSON_IsNumber(m)) {
|
||||
*method_out = (key_store_method_t)m->valuedouble;
|
||||
}
|
||||
}
|
||||
rc = 0;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return rc;
|
||||
}
|
||||
|
||||
+38
-33
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* key_store.h — Nostr identity persistence for sovereign_browser
|
||||
* key_store.h — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
|
||||
* so the browser can restore the signer on startup without re-prompting.
|
||||
* IMPORTANT: Private keys are NEVER persisted to disk. They live only in
|
||||
* working memory (RAM) for the duration of the session and are gone when
|
||||
* the browser quits. The user must sign in again on each launch.
|
||||
*
|
||||
* The key_store_identity_t struct is populated by the login dialog or CLI
|
||||
* login, held in memory, and used to create a nostr_signer_t. It is not
|
||||
* written to any file.
|
||||
*/
|
||||
|
||||
#ifndef KEY_STORE_H
|
||||
@@ -18,14 +23,14 @@ extern "C" {
|
||||
/* Login methods supported by the browser. */
|
||||
typedef enum {
|
||||
KEY_STORE_METHOD_NONE = 0,
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */
|
||||
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
|
||||
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
|
||||
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
|
||||
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
|
||||
} key_store_method_t;
|
||||
|
||||
/* Identity record persisted to disk. */
|
||||
/* Identity record (in-memory only — never persisted). */
|
||||
typedef struct {
|
||||
key_store_method_t method;
|
||||
|
||||
@@ -33,10 +38,10 @@ typedef struct {
|
||||
char pubkey_hex[65];
|
||||
|
||||
/* Hex private key (local + seed methods only; 64 chars + NUL).
|
||||
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
|
||||
* Held in memory only — never written to disk. */
|
||||
char privkey_hex[65];
|
||||
|
||||
/* BIP-39 mnemonic (seed method only). */
|
||||
/* BIP-39 mnemonic (seed method only). In memory only. */
|
||||
char mnemonic[256];
|
||||
|
||||
/* NIP-46 bunker URL (nip46 method only). */
|
||||
@@ -49,32 +54,7 @@ typedef struct {
|
||||
} key_store_identity_t;
|
||||
|
||||
/*
|
||||
* Get the path to the identity file (~/.sovereign_browser/identity.json).
|
||||
* Creates the directory if it doesn't exist.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_path(char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Save an identity to disk.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_save(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Load an identity from disk.
|
||||
* Returns 0 on success, 1 if no identity file exists, -1 on error.
|
||||
*/
|
||||
int key_store_load(key_store_identity_t *out);
|
||||
|
||||
/*
|
||||
* Delete the identity file (logout).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_clear(void);
|
||||
|
||||
/*
|
||||
* Create a nostr_signer_t from a loaded identity.
|
||||
* Create a nostr_signer_t from an in-memory identity.
|
||||
* For readonly: returns NULL (caller should use pubkey_hex directly).
|
||||
* For nsigner: creates the appropriate transport-backed signer.
|
||||
* Caller must free the returned signer with nostr_signer_free().
|
||||
@@ -82,6 +62,31 @@ int key_store_clear(void);
|
||||
*/
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Defensively delete any legacy identity.json file from a previous
|
||||
* version that persisted keys to disk. Call once at startup.
|
||||
* Returns 0 on success (including if the file doesn't exist), -1 on error.
|
||||
*/
|
||||
int key_store_delete_legacy_file(void);
|
||||
|
||||
/*
|
||||
* Save public identity info (method, pubkey_hex) to the per-profile
|
||||
* identity.json at ~/.sovereign_browser/profiles/<pubkey>/identity.json.
|
||||
* Private keys are NEVER stored — only public information for display.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t method);
|
||||
|
||||
/*
|
||||
* Load public identity info from the per-profile identity.json.
|
||||
* pubkey_hex — the expected pubkey (must match the file's pubkey)
|
||||
* method_out — if non-NULL, receives the stored login method
|
||||
* Returns 0 on success, -1 if not found or pubkey mismatch.
|
||||
*/
|
||||
int key_store_load_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t *method_out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+59
-3
@@ -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;
|
||||
@@ -1053,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));
|
||||
|
||||
@@ -1071,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();
|
||||
@@ -1140,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);
|
||||
|
||||
+534
-219
@@ -19,7 +19,8 @@
|
||||
* Right, Duplicate, Reload)
|
||||
* - Tab drag reordering
|
||||
* - Session save/restore (configurable in Settings)
|
||||
* - Settings dialog (tab preferences, persisted to disk)
|
||||
* - Settings page (sovereign://settings internal webpage: tabs, agent
|
||||
* server, security — persisted to disk)
|
||||
*
|
||||
* Build: make. Run: ./sovereign_browser [url]
|
||||
*/
|
||||
@@ -38,8 +39,17 @@
|
||||
#include "nostr_inject.h"
|
||||
#include "history.h"
|
||||
#include "settings.h"
|
||||
#include "shortcuts.h"
|
||||
#include "settings_sync.h"
|
||||
#include "tab_manager.h"
|
||||
#include "session.h"
|
||||
#include "agent_server.h"
|
||||
#include "agent_login.h"
|
||||
#include "cli.h"
|
||||
#include "db.h"
|
||||
#include "profile.h"
|
||||
#include "relay_fetch.h"
|
||||
#include "bookmarks.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -56,7 +66,56 @@ typedef struct {
|
||||
} app_state_t;
|
||||
|
||||
static app_state_t g_state = {0};
|
||||
|
||||
/* Forward declaration — defined before main(). */
|
||||
static int switch_to_user_db(const char *pubkey_hex);
|
||||
static GtkWindow *g_window = NULL;
|
||||
static gboolean g_logged_in = FALSE;
|
||||
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
|
||||
|
||||
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
|
||||
|
||||
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;
|
||||
|
||||
/* Update modules that hold a signer reference. */
|
||||
settings_sync_set_signer(signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* If this is the first login (we're still on global.db), switch to
|
||||
* the per-user profile database. If we're already on a per-user db
|
||||
* (e.g. switching identity at runtime), switch_to_user_db() will
|
||||
* close it and open the new user's db. */
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
}
|
||||
|
||||
void app_clear_signer(void) {
|
||||
if (g_state.signer) {
|
||||
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;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -83,6 +142,13 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
|
||||
/* Switch to the new user's per-user profile database. This
|
||||
* closes the current browser.db and opens the new user's
|
||||
* browser.db, then loads their per-user settings. */
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
g_print("[identity] switched: method=%d pubkey=%s\n",
|
||||
g_state.method, g_state.pubkey_hex);
|
||||
}
|
||||
@@ -110,7 +176,6 @@ void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
key_store_clear();
|
||||
g_print("[identity] logged out\n");
|
||||
}
|
||||
|
||||
@@ -156,192 +221,65 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
|
||||
gtk_widget_show_all(dialog);
|
||||
}
|
||||
|
||||
/* ---- Settings dialog ------------------------------------------------ */
|
||||
|
||||
static void on_settings_dialog_response(GtkDialog *dialog, gint response_id,
|
||||
gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (response_id == GTK_RESPONSE_ACCEPT) {
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
|
||||
/* Read the widgets by name from the dialog's content area. */
|
||||
GtkWidget *content = gtk_dialog_get_content_area(dialog);
|
||||
GList *children = gtk_container_get_children(GTK_CONTAINER(content));
|
||||
for (GList *l = children; l != NULL; l = l->next) {
|
||||
GtkWidget *child = GTK_WIDGET(l->data);
|
||||
const char *name = gtk_widget_get_name(child);
|
||||
if (name == NULL) continue;
|
||||
|
||||
if (strcmp(name, "restore_session") == 0) {
|
||||
s->restore_session = gtk_toggle_button_get_active(
|
||||
GTK_TOGGLE_BUTTON(child));
|
||||
} else if (strcmp(name, "new_tab_url") == 0) {
|
||||
const char *text = gtk_entry_get_text(GTK_ENTRY(child));
|
||||
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s", text);
|
||||
} else if (strcmp(name, "show_tab_close_buttons") == 0) {
|
||||
s->show_tab_close_buttons = gtk_toggle_button_get_active(
|
||||
GTK_TOGGLE_BUTTON(child));
|
||||
} else if (strcmp(name, "middle_click_close") == 0) {
|
||||
s->middle_click_close = gtk_toggle_button_get_active(
|
||||
GTK_TOGGLE_BUTTON(child));
|
||||
} else if (strcmp(name, "ctrl_tab_switch") == 0) {
|
||||
s->ctrl_tab_switch = gtk_toggle_button_get_active(
|
||||
GTK_TOGGLE_BUTTON(child));
|
||||
} else if (strcmp(name, "tab_drag_reorder") == 0) {
|
||||
s->tab_drag_reorder = gtk_toggle_button_get_active(
|
||||
GTK_TOGGLE_BUTTON(child));
|
||||
} else if (strcmp(name, "max_tabs") == 0) {
|
||||
const char *text = gtk_entry_get_text(GTK_ENTRY(child));
|
||||
s->max_tabs = atoi(text);
|
||||
if (s->max_tabs < 1) s->max_tabs = 1;
|
||||
} else if (strcmp(name, "tab_bar_position") == 0) {
|
||||
gint active = gtk_combo_box_get_active(GTK_COMBO_BOX(child));
|
||||
switch (active) {
|
||||
case 0: s->tab_bar_position = GTK_POS_TOP; break;
|
||||
case 1: s->tab_bar_position = GTK_POS_BOTTOM; break;
|
||||
case 2: s->tab_bar_position = GTK_POS_LEFT; break;
|
||||
case 3: s->tab_bar_position = GTK_POS_RIGHT; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_list_free(children);
|
||||
|
||||
settings_save();
|
||||
tab_manager_apply_settings();
|
||||
g_print("[settings] Saved\n");
|
||||
}
|
||||
gtk_widget_destroy(GTK_WIDGET(dialog));
|
||||
}
|
||||
/* ---- 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.
|
||||
*/
|
||||
|
||||
void on_menu_settings(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
if (window == NULL) return;
|
||||
(void)data;
|
||||
|
||||
const browser_settings_t *s = settings_get();
|
||||
|
||||
GtkWidget *dialog = gtk_dialog_new_with_buttons(
|
||||
"Settings", window,
|
||||
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_OK", GTK_RESPONSE_ACCEPT,
|
||||
NULL);
|
||||
gtk_window_set_default_size(GTK_WINDOW(dialog), 400, 350);
|
||||
|
||||
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
|
||||
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
|
||||
GtkBox *vbox = GTK_BOX(content);
|
||||
|
||||
/* Restore session checkbox. */
|
||||
GtkWidget *cb_restore = gtk_check_button_new_with_label(
|
||||
"Restore session on startup");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_restore),
|
||||
s->restore_session);
|
||||
gtk_widget_set_name(cb_restore, "restore_session");
|
||||
gtk_box_pack_start(vbox, cb_restore, FALSE, FALSE, 4);
|
||||
|
||||
/* New tab URL entry. */
|
||||
GtkWidget *lbl_url = gtk_label_new("New tab URL:");
|
||||
gtk_widget_set_halign(lbl_url, GTK_ALIGN_START);
|
||||
gtk_box_pack_start(vbox, lbl_url, FALSE, FALSE, 4);
|
||||
GtkWidget *entry_url = gtk_entry_new();
|
||||
gtk_entry_set_text(GTK_ENTRY(entry_url), s->new_tab_url);
|
||||
gtk_widget_set_name(entry_url, "new_tab_url");
|
||||
gtk_box_pack_start(vbox, entry_url, FALSE, FALSE, 4);
|
||||
|
||||
/* Tab bar position combo. */
|
||||
GtkWidget *lbl_pos = gtk_label_new("Tab bar position:");
|
||||
gtk_widget_set_halign(lbl_pos, GTK_ALIGN_START);
|
||||
gtk_box_pack_start(vbox, lbl_pos, FALSE, FALSE, 4);
|
||||
GtkWidget *combo_pos = gtk_combo_box_text_new();
|
||||
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Top");
|
||||
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Bottom");
|
||||
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Left");
|
||||
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Right");
|
||||
int pos_active = 0;
|
||||
switch (s->tab_bar_position) {
|
||||
case GTK_POS_TOP: pos_active = 0; break;
|
||||
case GTK_POS_BOTTOM: pos_active = 1; break;
|
||||
case GTK_POS_LEFT: pos_active = 2; break;
|
||||
case GTK_POS_RIGHT: pos_active = 3; break;
|
||||
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");
|
||||
}
|
||||
gtk_combo_box_set_active(GTK_COMBO_BOX(combo_pos), pos_active);
|
||||
gtk_widget_set_name(combo_pos, "tab_bar_position");
|
||||
gtk_box_pack_start(vbox, combo_pos, FALSE, FALSE, 4);
|
||||
|
||||
/* Show tab close buttons. */
|
||||
GtkWidget *cb_close_btn = gtk_check_button_new_with_label(
|
||||
"Show tab close buttons");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_close_btn),
|
||||
s->show_tab_close_buttons);
|
||||
gtk_widget_set_name(cb_close_btn, "show_tab_close_buttons");
|
||||
gtk_box_pack_start(vbox, cb_close_btn, FALSE, FALSE, 4);
|
||||
|
||||
/* Middle-click to close. */
|
||||
GtkWidget *cb_mid_click = gtk_check_button_new_with_label(
|
||||
"Middle-click to close tab");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_mid_click),
|
||||
s->middle_click_close);
|
||||
gtk_widget_set_name(cb_mid_click, "middle_click_close");
|
||||
gtk_box_pack_start(vbox, cb_mid_click, FALSE, FALSE, 4);
|
||||
|
||||
/* Ctrl+Tab switching. */
|
||||
GtkWidget *cb_ctrl_tab = gtk_check_button_new_with_label(
|
||||
"Ctrl+Tab to switch tabs");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_ctrl_tab),
|
||||
s->ctrl_tab_switch);
|
||||
gtk_widget_set_name(cb_ctrl_tab, "ctrl_tab_switch");
|
||||
gtk_box_pack_start(vbox, cb_ctrl_tab, FALSE, FALSE, 4);
|
||||
|
||||
/* Tab drag reordering. */
|
||||
GtkWidget *cb_drag = gtk_check_button_new_with_label(
|
||||
"Allow drag to reorder tabs");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_drag),
|
||||
s->tab_drag_reorder);
|
||||
gtk_widget_set_name(cb_drag, "tab_drag_reorder");
|
||||
gtk_box_pack_start(vbox, cb_drag, FALSE, FALSE, 4);
|
||||
|
||||
/* Max tabs. */
|
||||
GtkWidget *lbl_max = gtk_label_new("Maximum tabs:");
|
||||
gtk_widget_set_halign(lbl_max, GTK_ALIGN_START);
|
||||
gtk_box_pack_start(vbox, lbl_max, FALSE, FALSE, 4);
|
||||
GtkWidget *entry_max = gtk_entry_new();
|
||||
char max_str[16];
|
||||
snprintf(max_str, sizeof(max_str), "%d", s->max_tabs);
|
||||
gtk_entry_set_text(GTK_ENTRY(entry_max), max_str);
|
||||
gtk_widget_set_name(entry_max, "max_tabs");
|
||||
gtk_box_pack_start(vbox, entry_max, FALSE, FALSE, 4);
|
||||
|
||||
g_signal_connect(dialog, "response",
|
||||
G_CALLBACK(on_settings_dialog_response), NULL);
|
||||
|
||||
gtk_widget_show_all(dialog);
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- */
|
||||
void on_menu_profile(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
|
||||
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
gpointer data) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
|
||||
} else {
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- *
|
||||
* All browser-level shortcuts are configurable via the shortcuts module.
|
||||
* The on_key_press handler looks up the action for the pressed key
|
||||
* combination and dispatches it. Bindings are set on the
|
||||
* sovereign://settings page (key-capture UI) and persisted to SQLite.
|
||||
*/
|
||||
|
||||
/* Made non-static so tab_manager.c can connect it to each webview. */
|
||||
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();
|
||||
int action = shortcuts_lookup(event);
|
||||
if (action < 0) return FALSE;
|
||||
|
||||
/* Ctrl+T — new tab. */
|
||||
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_t) {
|
||||
switch ((shortcut_action_t)action) {
|
||||
|
||||
case SHORTCUT_NEW_TAB:
|
||||
tab_manager_new_tab(NULL);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Ctrl+W — close active tab. */
|
||||
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_w) {
|
||||
case SHORTCUT_CLOSE_TAB:
|
||||
tab_manager_close_active();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Ctrl+L — focus URL bar of active tab. */
|
||||
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_l) {
|
||||
case SHORTCUT_FOCUS_URL: {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->url_entry) {
|
||||
gtk_widget_grab_focus(tab->url_entry);
|
||||
@@ -350,35 +288,94 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs. */
|
||||
if (s->ctrl_tab_switch && (mods & GDK_CONTROL_MASK)) {
|
||||
if (event->keyval == GDK_KEY_Tab) {
|
||||
if (mods & GDK_SHIFT_MASK) {
|
||||
tab_manager_prev();
|
||||
} else {
|
||||
tab_manager_next();
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if (event->keyval == GDK_KEY_ISO_Left_Tab) {
|
||||
tab_manager_prev();
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ctrl+PageDown — next tab. */
|
||||
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Down) {
|
||||
case SHORTCUT_NEXT_TAB:
|
||||
tab_manager_next();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Ctrl+PageUp — previous tab. */
|
||||
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Up) {
|
||||
case SHORTCUT_PREV_TAB:
|
||||
tab_manager_prev();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_NEXT_TAB_PAGEDOWN:
|
||||
tab_manager_next();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_PREV_TAB_PAGEUP:
|
||||
tab_manager_prev();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_RELOAD: {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview)
|
||||
webkit_web_view_reload(tab->webview);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
case SHORTCUT_FORCE_RELOAD: {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview)
|
||||
webkit_web_view_reload_bypass_cache(tab->webview);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
case SHORTCUT_GO_BACK: {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview))
|
||||
webkit_web_view_go_back(tab->webview);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
case SHORTCUT_GO_FORWARD: {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview))
|
||||
webkit_web_view_go_forward(tab->webview);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
case SHORTCUT_FIND:
|
||||
/* Phase 2: wire WebKitFindController + find bar.
|
||||
* For now, log so the user knows the binding fired. */
|
||||
g_print("[shortcut] Find in page (not yet implemented)\n");
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_OPEN_SETTINGS:
|
||||
on_menu_settings(NULL, NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_NEW_IDENTITY:
|
||||
app_menu_switch_identity_proxy(NULL, g_window);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_TOGGLE_FULLSCREEN: {
|
||||
if (g_is_fullscreen) {
|
||||
gtk_window_unfullscreen(g_window);
|
||||
g_is_fullscreen = FALSE;
|
||||
} else {
|
||||
gtk_window_fullscreen(g_window);
|
||||
g_is_fullscreen = TRUE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
case SHORTCUT_TOGGLE_INSPECTOR:
|
||||
tab_manager_toggle_inspector();
|
||||
return TRUE;
|
||||
|
||||
default:
|
||||
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 ------------------------------------------------- */
|
||||
@@ -390,35 +387,135 @@ static void on_window_destroy(GtkWidget *widget, gpointer 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();
|
||||
bookmarks_cleanup();
|
||||
db_close();
|
||||
gtk_main_quit();
|
||||
}
|
||||
|
||||
/* ---- Login flow ----------------------------------------------------- */
|
||||
/* ---- 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
login_result_t result;
|
||||
if (login_dialog_run(parent, &result) != 0) {
|
||||
return -1;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ---- Per-user database switch --------------------------------------- *
|
||||
* After login, switch from the global database to the per-user profile
|
||||
* database. Creates the profile directory if needed, closes global.db,
|
||||
* opens the per-user browser.db, and loads per-user settings.
|
||||
*
|
||||
* This function is called from:
|
||||
* - app_set_signer() — when an agent logs in via MCP or CLI
|
||||
* - app_menu_switch_identity_proxy() — runtime identity switch via menu
|
||||
* - main() — after the GTK login dialog returns
|
||||
*
|
||||
* It is idempotent: if already on the correct per-user db, it's a no-op.
|
||||
*/
|
||||
|
||||
/* Track the currently-open per-user db path so we can skip re-opening
|
||||
* the same database (e.g. when app_set_signer() is called during the
|
||||
* login dialog and then main() calls switch_to_user_db() again). */
|
||||
static char g_current_profile_db[512] = "";
|
||||
|
||||
static int switch_to_user_db(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[profile] No pubkey, staying on global.db\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Create the profile directory. */
|
||||
if (profile_ensure_dir(pubkey_hex) != 0) {
|
||||
g_printerr("[profile] Failed to create profile dir for %s\n",
|
||||
pubkey_hex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Open the per-user browser.db (closes global.db first). */
|
||||
char db_path[512];
|
||||
profile_get_db_path(pubkey_hex, db_path, sizeof(db_path));
|
||||
if (db_path[0] == '\0') {
|
||||
g_printerr("[profile] Failed to get db path for %s\n", pubkey_hex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Skip if already on this database (idempotent). */
|
||||
if (g_current_profile_db[0] != '\0' &&
|
||||
strcmp(g_current_profile_db, db_path) == 0) {
|
||||
g_print("[profile] Already on per-user db: %s\n", db_path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (db_init_with_path(db_path) != 0) {
|
||||
g_printerr("[profile] Failed to open per-user db: %s\n", db_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Record the current profile db path (for idempotency check). */
|
||||
snprintf(g_current_profile_db, sizeof(g_current_profile_db),
|
||||
"%s", db_path);
|
||||
|
||||
/* Load per-user settings from the per-user browser.db. This only
|
||||
* reads per-user keys; global settings already in memory are
|
||||
* preserved. */
|
||||
settings_load_user();
|
||||
|
||||
/* Save the last-used pubkey to the global identity.json so the
|
||||
* login dialog can default to this profile next time. */
|
||||
profile_save_last_pubkey(pubkey_hex);
|
||||
|
||||
/* Save public identity info (method, pubkey) to the per-profile
|
||||
* identity.json. Private keys are never stored. */
|
||||
key_store_save_profile_identity(pubkey_hex, g_state.method);
|
||||
|
||||
g_print("[profile] Switched to per-user db: %s\n", db_path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -427,13 +524,99 @@ static int do_login(GtkWindow *parent) {
|
||||
int main(int argc, char **argv) {
|
||||
(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 settings and history from disk. */
|
||||
settings_load();
|
||||
history_load();
|
||||
/* NOTE: key_store_delete_legacy_file() was previously called here
|
||||
* to delete identity.json from old versions that persisted private
|
||||
* keys. We no longer call it because identity.json is now used
|
||||
* legitimately to store the last-used pubkey_hex (no private keys).
|
||||
* This is a new project with no legacy files to migrate. */
|
||||
|
||||
const char *start_url = (argc > 1) ? argv[1] : NULL;
|
||||
/* Initialize the global database first — global settings and
|
||||
* shortcuts are stored there. The per-user browser.db is opened
|
||||
* after login, once we know the user's pubkey. */
|
||||
db_init_global();
|
||||
|
||||
/* Set defaults, then load global settings from global.db. Per-user
|
||||
* settings are loaded after login from the per-user browser.db. */
|
||||
{
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
/* settings_load_global() does NOT reset defaults, so we need to
|
||||
* set them first. We call settings_load() which sets defaults
|
||||
* and then loads global settings from the currently-open db
|
||||
* (global.db). The per-user keys won't be found in global.db,
|
||||
* so their defaults are kept. */
|
||||
(void)s;
|
||||
settings_load();
|
||||
}
|
||||
|
||||
/* Load keyboard shortcut bindings from the database (global.db).
|
||||
* Must come after settings_load() because shortcuts_lookup() checks
|
||||
* the master ctrl_tab_switch toggle. Shortcuts are global — they
|
||||
* are the same across all profiles. */
|
||||
shortcuts_load();
|
||||
|
||||
/* History is queried from the SQLite database on demand — no
|
||||
* separate load step needed. */
|
||||
|
||||
/* 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);
|
||||
@@ -443,13 +626,119 @@ int main(int argc, char **argv) {
|
||||
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(g_window) != 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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");
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Switch to per-user profile database ────────────────────── *
|
||||
* Now that we know the user's pubkey, close global.db and open the
|
||||
* per-user browser.db at ~/.sovereign_browser/profiles/<pubkey>/.
|
||||
* This must happen before relay_fetch, session_restore, bookmarks,
|
||||
* etc. — all of which read/write the per-user database.
|
||||
*
|
||||
* If there's no pubkey (--no-login mode), we stay on global.db.
|
||||
* Browsing still works; history/session just go to global.db in
|
||||
* that case (acceptable for the no-identity mode). */
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
if (switch_to_user_db(g_state.pubkey_hex) != 0) {
|
||||
g_printerr("[profile] Failed to switch to per-user db — "
|
||||
"continuing with global.db\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 per-user 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);
|
||||
}
|
||||
|
||||
/* Initialize the bookmarks module. Loads cached kind 30003 events
|
||||
* from SQLite and decrypts them. In no-login/read-only mode, the
|
||||
* signer is NULL so bookmarks are read-only. */
|
||||
bookmarks_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Initialize the NIP-78 settings sync module. In no-login/read-only
|
||||
* mode, the signer is NULL so settings are local-only (not synced). */
|
||||
settings_sync_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Set the user's avatar on the tab bar from their kind 0 profile.
|
||||
* If the profile hasn't been fetched yet (relay fetch is still
|
||||
* running), this shows the default icon. The avatar will be updated
|
||||
* when the relay fetch completes and the kind 0 event is stored. */
|
||||
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Use the default WebKitWebContext — it comes with proper networking
|
||||
* (cookies, cache, soup session) that a freshly created context lacks.
|
||||
* All tabs will create webviews from this shared context. */
|
||||
@@ -458,10 +747,12 @@ int main(int argc, char **argv) {
|
||||
/* ── Security strip: disable web security restrictions ─────── */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(web_ctx);
|
||||
/* Register sovereign:// as secure only. Do NOT register it as "local"
|
||||
* (WebKit blocks fetch from https to local origins) and do NOT register
|
||||
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
|
||||
* which we can't set with the basic finish API). Without these, WebKit
|
||||
* treats sovereign:// as a simple secure scheme with no CORS checks. */
|
||||
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");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
@@ -471,6 +762,14 @@ int main(int argc, char **argv) {
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* 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);
|
||||
@@ -482,15 +781,29 @@ int main(int argc, char **argv) {
|
||||
/* Initialize the tab manager. */
|
||||
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
|
||||
|
||||
/* Try to restore the previous session. If nothing was restored,
|
||||
* open a single tab with the start URL or the default new-tab URL. */
|
||||
int restored = session_restore();
|
||||
/* 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) {
|
||||
const char *url = start_url;
|
||||
if (url == NULL || url[0] == '\0') {
|
||||
url = settings_get()->new_tab_url;
|
||||
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);
|
||||
}
|
||||
tab_manager_new_tab(url);
|
||||
}
|
||||
|
||||
/* Wire the security refs to the first tab's settings (settings are
|
||||
@@ -505,6 +818,8 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
gtk_main();
|
||||
|
||||
|
||||
+1357
-100
File diff suppressed because it is too large
Load Diff
+28
-12
@@ -27,7 +27,6 @@ static const char *NOSTR_SHIM_JS =
|
||||
"\n"
|
||||
" function bridgeCall(method, bodyObj) {\n"
|
||||
" var url = BRIDGE_BASE + method;\n"
|
||||
" var opts = {};\n"
|
||||
"\n"
|
||||
" if (bodyObj !== undefined && bodyObj !== null) {\n"
|
||||
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
|
||||
@@ -36,20 +35,37 @@ static const char *NOSTR_SHIM_JS =
|
||||
" url += '?body=' + encodeURIComponent(bodyJson);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" return fetch(url).then(function(resp) {\n"
|
||||
" return resp.text();\n"
|
||||
" }).then(function(text) {\n"
|
||||
" var data;\n"
|
||||
" /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR\n"
|
||||
" * enforce CORS on custom schemes even when registered as secure, but\n"
|
||||
" * synchronous XHR to a secure custom scheme works without CORS headers.\n"
|
||||
" * The bridge is local (sovereign:// URI scheme handler in C), so the\n"
|
||||
" * call is effectively instant. */\n"
|
||||
" return new Promise(function(resolve, reject) {\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(text);\n"
|
||||
" var xhr = new XMLHttpRequest();\n"
|
||||
" xhr.open('GET', url, false); /* synchronous */\n"
|
||||
" xhr.send();\n"
|
||||
" var text = xhr.responseText;\n"
|
||||
" if (!text) {\n"
|
||||
" reject(new Error('Bridge returned empty response'));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" var data;\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(text);\n"
|
||||
" } catch(e) {\n"
|
||||
" reject(new Error('Bridge returned invalid JSON: ' + text));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (data.error !== undefined) {\n"
|
||||
" var msg = data.message || ('Error code ' + data.error);\n"
|
||||
" reject(new Error(msg));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" resolve(data);\n"
|
||||
" } catch(e) {\n"
|
||||
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
|
||||
" reject(new Error('Bridge request failed: ' + e.message));\n"
|
||||
" }\n"
|
||||
" if (data.error !== undefined) {\n"
|
||||
" var msg = data.message || ('Error code ' + data.error);\n"
|
||||
" throw new Error(msg);\n"
|
||||
" }\n"
|
||||
" return data;\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* profile.c — per-user profile directory management for sovereign_browser
|
||||
*
|
||||
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
|
||||
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/.
|
||||
*/
|
||||
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Internal helpers ──────────────────────────────────────────────── */
|
||||
|
||||
/* Get ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
|
||||
static int sb_home_dir(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Max path length we use for home-derived paths. The home directory
|
||||
* can be long, and we append "/.sovereign_browser/profiles/<64-hex>/".
|
||||
* 600 bytes is generous enough for any realistic HOME path. */
|
||||
#define SB_HOME_BUF_SZ 600
|
||||
|
||||
/* mkdir -p style: create a directory and all parent directories. */
|
||||
static int mkdir_p(const char *path, mode_t mode) {
|
||||
char tmp[512];
|
||||
int n = snprintf(tmp, sizeof(tmp), "%s", path);
|
||||
if (n < 0 || (size_t)n >= sizeof(tmp)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Remove trailing slash if present. */
|
||||
size_t len = strlen(tmp);
|
||||
if (len > 0 && tmp[len - 1] == '/') {
|
||||
tmp[len - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Walk the path and create each component. */
|
||||
for (char *p = tmp + 1; *p; p++) {
|
||||
if (*p == '/') {
|
||||
*p = '\0';
|
||||
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char home[600];
|
||||
if (sb_home_dir(home, sizeof(home)) != 0) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%s/profiles/%s/", home, pubkey_hex);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int profile_ensure_dir(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return mkdir_p(dir, 0700);
|
||||
}
|
||||
|
||||
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%sbrowser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%sidentity.json", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void profile_get_global_identity_path(char *out, size_t out_sz) {
|
||||
if (out == NULL || out_sz == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char home[SB_HOME_BUF_SZ];
|
||||
if (sb_home_dir(home, sizeof(home)) != 0) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%s/identity.json", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Last-used pubkey persistence (global identity.json) ───────────── */
|
||||
|
||||
int profile_save_last_pubkey(const char *pubkey_hex) {
|
||||
char path[SB_HOME_BUF_SZ];
|
||||
profile_get_global_identity_path(path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (pubkey_hex && pubkey_hex[0]) {
|
||||
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
|
||||
} else {
|
||||
cJSON_AddNullToObject(root, "pubkey_hex");
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
if (json == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
fputs(json, f);
|
||||
fputc('\n', f);
|
||||
fclose(f);
|
||||
free(json);
|
||||
|
||||
g_print("[profile] Saved last-used pubkey: %s\n",
|
||||
(pubkey_hex && pubkey_hex[0]) ? pubkey_hex : "(cleared)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int profile_load_last_pubkey(char *out, size_t out_sz) {
|
||||
if (out == NULL || out_sz < 65) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[SB_HOME_BUF_SZ];
|
||||
profile_get_global_identity_path(path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Read the file (small JSON, a few hundred bytes at most). */
|
||||
char buf[1024];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
|
||||
const char *hex = cJSON_IsString(pk) ? pk->valuestring : NULL;
|
||||
|
||||
int rc = -1;
|
||||
if (hex && strlen(hex) == 64) {
|
||||
snprintf(out, out_sz, "%s", hex);
|
||||
rc = 0;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* profile.h — per-user profile directory management for sovereign_browser
|
||||
*
|
||||
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
|
||||
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/
|
||||
* containing a per-user browser.db and identity.json.
|
||||
*
|
||||
* Global settings (agent server, theme, shortcuts, inspector geometry)
|
||||
* live in ~/.sovereign_browser/global.db and are shared across all users.
|
||||
*/
|
||||
|
||||
#ifndef PROFILE_H
|
||||
#define PROFILE_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Get the profile directory path for a given hex pubkey.
|
||||
* pubkey_hex — 64-char hex pubkey (no leading "0x")
|
||||
* out — output buffer
|
||||
* out_sz — size of output buffer
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/" to out.
|
||||
*/
|
||||
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Create the profile directory for the given hex pubkey if it doesn't
|
||||
* already exist (mkdir -p style — creates parent dirs as needed).
|
||||
* pubkey_hex — 64-char hex pubkey
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_ensure_dir(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Get the per-user browser.db path for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/browser.db" to out.
|
||||
*/
|
||||
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Get the per-user identity.json path for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/identity.json" to out.
|
||||
*/
|
||||
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Get the global identity.json path (stores the last-used pubkey_hex so
|
||||
* the login dialog can default to the right profile).
|
||||
* Writes "~/.sovereign_browser/identity.json" to out.
|
||||
*/
|
||||
void profile_get_global_identity_path(char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Save the last-used pubkey_hex to the global identity.json.
|
||||
* pubkey_hex — 64-char hex pubkey, or NULL/empty to clear
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_save_last_pubkey(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Read the last-used pubkey_hex from the global identity.json.
|
||||
* out — output buffer (must be at least 65 bytes)
|
||||
* out_sz — size of output buffer
|
||||
* Returns 0 on success (out filled with hex pubkey), -1 if not found or
|
||||
* error (out is left unchanged).
|
||||
*/
|
||||
int profile_load_last_pubkey(char *out, size_t out_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PROFILE_H */
|
||||
@@ -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,184 @@
|
||||
/*
|
||||
* relay_fetch.c — post-login bootstrap relay fetch for sovereign_browser
|
||||
*
|
||||
* Uses synchronous_query_relays_with_progress() from nostr_core_lib to
|
||||
* query the user's kind 0/3/10002 events from the bootstrap relays, then
|
||||
* stores them in the SQLite database via db_store_event().
|
||||
*
|
||||
* This runs in a background thread (relay_fetch_thread) so the browser
|
||||
* is usable immediately after login. SQLite is opened with
|
||||
* SQLITE_OPEN_FULLMUTEX so concurrent access from the main thread is safe.
|
||||
*/
|
||||
|
||||
#include "relay_fetch.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
#include "bookmarks.h"
|
||||
#include "settings_sync.h"
|
||||
#include "tab_manager.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <glib.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* Maximum number of relays to parse from the bootstrap_relays setting. */
|
||||
#define MAX_RELAYS 32
|
||||
|
||||
/* Relay query timeout in seconds. */
|
||||
#define RELAY_TIMEOUT_SECONDS 15
|
||||
|
||||
/* Forward declaration — defined after relay_fetch_bootstrap. */
|
||||
static gboolean avatar_refresh_idle(gpointer data);
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
const char **relay_urls,
|
||||
int relay_count) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[relay] No pubkey, skipping bootstrap fetch\n");
|
||||
return -1;
|
||||
}
|
||||
if (relay_urls == NULL || relay_count <= 0) {
|
||||
g_printerr("[relay] No bootstrap relays configured\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[relay] Fetching kind 0/3/10002/30003/30078 for %s from %d relay(s)...\n",
|
||||
pubkey_hex, relay_count);
|
||||
|
||||
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* bookmark sets */
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30078)); /* NIP-78 app data */
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
|
||||
* from all relays. No NIP-42 auth needed for public events. */
|
||||
int result_count = 0;
|
||||
cJSON **results = synchronous_query_relays_with_progress(
|
||||
relay_urls, relay_count, filter,
|
||||
RELAY_QUERY_ALL_RESULTS, &result_count,
|
||||
RELAY_TIMEOUT_SECONDS,
|
||||
NULL, NULL, /* no progress callback */
|
||||
0, NULL /* no NIP-42 auth */
|
||||
);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (results == NULL || result_count <= 0) {
|
||||
g_print("[relay] No events found (relays may be unreachable or "
|
||||
"the user has no kind 0/3/10002 events)\n");
|
||||
if (results) free(results);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Store each event in the database. Kind 30003 (bookmark sets) are
|
||||
* also decrypted and loaded into the in-memory bookmarks list.
|
||||
* Kind 30078 (NIP-78 app data) is stored and merged into local
|
||||
* settings if it's our sovereign_browser settings event. */
|
||||
int stored = 0;
|
||||
for (int i = 0; i < result_count; i++) {
|
||||
if (results[i] == NULL) continue;
|
||||
|
||||
long kind = 0;
|
||||
cJSON *k = cJSON_GetObjectItemCaseSensitive(results[i], "kind");
|
||||
if (cJSON_IsNumber(k)) kind = (long)k->valuedouble;
|
||||
|
||||
if (kind == 30003) {
|
||||
/* bookmarks_store_and_load_event stores in SQLite + decrypts. */
|
||||
if (bookmarks_store_and_load_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
} else if (kind == 30078) {
|
||||
/* Store in SQLite first, then try to merge into local settings.
|
||||
* settings_sync_merge_from_nostr checks the d-tag and only
|
||||
* merges if it's our "sovereign_browser" event. */
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
settings_sync_merge_from_nostr(results[i]);
|
||||
} else {
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
} else {
|
||||
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
|
||||
result_count);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(results[i]);
|
||||
}
|
||||
free(results);
|
||||
|
||||
g_print("[relay] Fetched %d event(s), stored %d in database\n",
|
||||
result_count, stored);
|
||||
|
||||
/* Refresh the user's avatar now that the kind 0 profile may be
|
||||
* available in the database. This must run on the main thread
|
||||
* because it touches GTK widgets. */
|
||||
g_idle_add(avatar_refresh_idle, g_strdup(pubkey_hex));
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* Idle callback wrapper to refresh the avatar on the main thread. */
|
||||
static gboolean avatar_refresh_idle(gpointer data) {
|
||||
char *pubkey = (char *)data;
|
||||
tab_manager_set_avatar(pubkey);
|
||||
g_free(pubkey);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── Background thread ─────────────────────────────────────────────── */
|
||||
|
||||
gpointer relay_fetch_thread(gpointer data) {
|
||||
char *pubkey_hex = (char *)data;
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[relay] Thread started with no pubkey\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Parse bootstrap relays from settings. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
char *relay_buf = g_strdup(s->bootstrap_relays);
|
||||
const char *relay_urls[MAX_RELAYS];
|
||||
int relay_count = 0;
|
||||
|
||||
/* Tokenize the buffer in place. */
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && relay_count < MAX_RELAYS) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
relay_urls[relay_count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
|
||||
if (relay_count == 0) {
|
||||
g_print("[relay] No valid bootstrap relay URLs in settings\n");
|
||||
g_free(relay_buf);
|
||||
g_free(pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
|
||||
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
|
||||
|
||||
g_free(relay_buf);
|
||||
g_free(pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* relay_fetch.h — post-login bootstrap relay fetch for sovereign_browser
|
||||
*
|
||||
* After the user logs in, fetches their kind 0 (profile), kind 3 (contacts),
|
||||
* and kind 10002 (relay list) events from the configured bootstrap relays
|
||||
* and stores them in the SQLite database.
|
||||
*/
|
||||
|
||||
#ifndef RELAY_FETCH_H
|
||||
#define RELAY_FETCH_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays
|
||||
* and store them in the SQLite database via db_store_event().
|
||||
*
|
||||
* pubkey_hex — user's hex pubkey (64 chars, no 0x prefix)
|
||||
* relay_urls — array of relay URL strings (e.g. "wss://relay.damus.io")
|
||||
* relay_count — number of relays in the array
|
||||
*
|
||||
* Returns the number of events fetched and stored, or -1 on error.
|
||||
*/
|
||||
int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
|
||||
/*
|
||||
* Background thread entry point for relay_fetch_bootstrap.
|
||||
* Pass a pointer to a g_strdup'd pubkey_hex string (the thread frees it).
|
||||
* Reads bootstrap relays from the global settings singleton.
|
||||
*
|
||||
* Usage: g_thread_new("relay-fetch", relay_fetch_thread, g_strdup(pubkey));
|
||||
*/
|
||||
gpointer relay_fetch_thread(gpointer data);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RELAY_FETCH_H */
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* search.c — search engine integration for sovereign_browser
|
||||
*
|
||||
* Implements search engine configuration, URL building, async autocomplete
|
||||
* suggestion fetching via libsoup, and a URL-vs-query heuristic.
|
||||
*
|
||||
* The active engine is stored in settings ("search_engine" key) and
|
||||
* synced via NIP-78 (settings_sync).
|
||||
*/
|
||||
|
||||
#include "search.h"
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <libsoup/soup.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Vendored cJSON for parsing suggestion API responses. */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Built-in search engines ────────────────────────────────────────── */
|
||||
|
||||
static const search_engine_t g_engines[] = {
|
||||
{
|
||||
"duckduckgo", "DuckDuckGo",
|
||||
"https://duckduckgo.com/?q=%s",
|
||||
"https://duckduckgo.com/ac/?q=%s&type=list"
|
||||
},
|
||||
{
|
||||
"google", "Google",
|
||||
"https://www.google.com/search?q=%s",
|
||||
"https://suggestqueries.google.com/complete/search?client=firefox&q=%s"
|
||||
},
|
||||
{
|
||||
"brave", "Brave Search",
|
||||
"https://search.brave.com/search?q=%s",
|
||||
"https://search.brave.com/api/suggest?q=%s"
|
||||
},
|
||||
{
|
||||
"startpage", "Startpage",
|
||||
"https://www.startpage.com/sp/search?query=%s",
|
||||
NULL /* no public autocomplete API */
|
||||
},
|
||||
{
|
||||
"searx", "Searx",
|
||||
"https://searx.be/search?q=%s",
|
||||
NULL
|
||||
},
|
||||
{ NULL, NULL, NULL, NULL } /* sentinel */
|
||||
};
|
||||
|
||||
const search_engine_t *search_engines_get(void) {
|
||||
return g_engines;
|
||||
}
|
||||
|
||||
int search_engines_count(void) {
|
||||
int count = 0;
|
||||
while (g_engines[count].id != NULL) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
const search_engine_t *search_engine_by_id(const char *id) {
|
||||
if (id == NULL || id[0] == '\0') return NULL;
|
||||
for (int i = 0; g_engines[i].id != NULL; i++) {
|
||||
if (strcmp(g_engines[i].id, id) == 0) {
|
||||
return &g_engines[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const search_engine_t *search_engine_get_active(void) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
const search_engine_t *engine = search_engine_by_id(s->search_engine);
|
||||
if (engine == NULL) {
|
||||
/* Fall back to DuckDuckGo if the configured engine is unknown. */
|
||||
engine = &g_engines[0];
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
int search_engine_set_active(const char *id) {
|
||||
if (search_engine_by_id(id) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
snprintf(s->search_engine, sizeof(s->search_engine), "%s", id);
|
||||
settings_save();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── URL building ──────────────────────────────────────────────────── */
|
||||
|
||||
char *search_build_search_url_for(const search_engine_t *engine,
|
||||
const char *query) {
|
||||
if (engine == NULL || query == NULL) return NULL;
|
||||
|
||||
/* URL-encode the query for use in a URL query parameter. */
|
||||
char *encoded = g_uri_escape_string(query, NULL, FALSE);
|
||||
if (encoded == NULL) return NULL;
|
||||
|
||||
char *url = g_strdup_printf(engine->search_url, encoded);
|
||||
g_free(encoded);
|
||||
return url;
|
||||
}
|
||||
|
||||
char *search_build_search_url(const char *query) {
|
||||
return search_build_search_url_for(search_engine_get_active(), query);
|
||||
}
|
||||
|
||||
/* ── URL heuristic ─────────────────────────────────────────────────── */
|
||||
|
||||
gboolean search_is_url(const char *input) {
|
||||
if (input == NULL || input[0] == '\0') return FALSE;
|
||||
|
||||
/* Has a scheme (e.g. "https://", "http://", "ftp://"). */
|
||||
if (strstr(input, "://") != NULL) return TRUE;
|
||||
|
||||
/* Internal about: pages. */
|
||||
if (strncmp(input, "about:", 6) == 0) return TRUE;
|
||||
|
||||
/* sovereign:// internal pages. */
|
||||
if (strncmp(input, "sovereign://", 12) == 0) return TRUE;
|
||||
|
||||
/* If it contains spaces, it's almost certainly a search query. */
|
||||
if (strchr(input, ' ') != NULL) return FALSE;
|
||||
|
||||
/* "localhost" or "localhost:port". */
|
||||
if (strncmp(input, "localhost", 9) == 0) return TRUE;
|
||||
|
||||
/* Check for a dot — looks like a domain name.
|
||||
* But require at least one char before and after the dot. */
|
||||
const char *dot = strchr(input, '.');
|
||||
if (dot != NULL && dot > input && dot[1] != '\0') {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Check for IPv4 address (4 numbers separated by dots). */
|
||||
{
|
||||
int a, b, c, d;
|
||||
if (sscanf(input, "%d.%d.%d.%d", &a, &b, &c, &d) == 4) {
|
||||
if (a >= 0 && a <= 255 && b >= 0 && b <= 255 &&
|
||||
c >= 0 && c <= 255 && d >= 0 && d <= 255) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* ── Async suggestion fetch ────────────────────────────────────────── */
|
||||
|
||||
/* A pending suggestion request. */
|
||||
typedef struct {
|
||||
guint id; /* unique request ID */
|
||||
SoupSession *session; /* libsoup session */
|
||||
SoupMessage *msg; /* in-flight HTTP request */
|
||||
search_suggest_callback callback;
|
||||
gpointer user_data;
|
||||
GCancellable *cancellable;
|
||||
} suggest_request_t;
|
||||
|
||||
/* Global counter for request IDs. */
|
||||
static guint g_next_request_id = 1;
|
||||
|
||||
/* Active requests, keyed by ID. We keep a simple list since there's
|
||||
* typically only one active at a time (the latest keystroke). */
|
||||
static GList *g_active_requests = NULL;
|
||||
|
||||
/*
|
||||
* Parse a suggestion API response.
|
||||
*
|
||||
* DuckDuckGo returns: ["query", ["sugg1", "sugg2", ...]]
|
||||
* Google (client=firefox) returns: ["query", ["sugg1", "sugg2", ...]]
|
||||
* Brave returns: ["sugg1", "sugg2", ...] (plain array of strings)
|
||||
*
|
||||
* Returns a NULL-terminated array of newly allocated strings, or NULL.
|
||||
*/
|
||||
static char **parse_suggestions(const char *body, gsize body_len) {
|
||||
if (body == NULL || body_len == 0) return NULL;
|
||||
|
||||
cJSON *root = cJSON_ParseWithLength(body, body_len);
|
||||
if (root == NULL) return NULL;
|
||||
|
||||
char **results = g_new0(char *, SEARCH_SUGGESTION_MAX + 1);
|
||||
int count = 0;
|
||||
|
||||
if (cJSON_IsArray(root)) {
|
||||
int arr_size = cJSON_GetArraySize(root);
|
||||
|
||||
/* DuckDuckGo / Google format: first element is the query string,
|
||||
* second element is an array of suggestions. */
|
||||
if (arr_size >= 2 && cJSON_IsString(cJSON_GetArrayItem(root, 0))) {
|
||||
cJSON *suggestions = cJSON_GetArrayItem(root, 1);
|
||||
if (cJSON_IsArray(suggestions)) {
|
||||
int sugg_size = cJSON_GetArraySize(suggestions);
|
||||
for (int i = 0; i < sugg_size && count < SEARCH_SUGGESTION_MAX; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(suggestions, i);
|
||||
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
|
||||
results[count++] = g_strdup(item->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Brave format: plain array of strings. */
|
||||
for (int i = 0; i < arr_size && count < SEARCH_SUGGESTION_MAX; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(root, i);
|
||||
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
|
||||
results[count++] = g_strdup(item->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (count == 0) {
|
||||
g_free(results);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/*
|
||||
* Idle callback to deliver results on the main thread.
|
||||
*/
|
||||
typedef struct {
|
||||
search_suggest_callback callback;
|
||||
char **suggestions;
|
||||
gpointer user_data;
|
||||
} idle_delivery_t;
|
||||
|
||||
static gboolean deliver_suggestions_idle(gpointer data) {
|
||||
idle_delivery_t *delivery = (idle_delivery_t *)data;
|
||||
delivery->callback(delivery->suggestions, delivery->user_data);
|
||||
|
||||
/* Free the suggestions array after the callback has consumed them. */
|
||||
if (delivery->suggestions) {
|
||||
for (int i = 0; delivery->suggestions[i] != NULL; i++) {
|
||||
g_free(delivery->suggestions[i]);
|
||||
}
|
||||
g_free(delivery->suggestions);
|
||||
}
|
||||
g_free(delivery);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/*
|
||||
* GAsyncReadyCallback for soup_session_send_and_read_async.
|
||||
* Called when the full response body has been downloaded.
|
||||
*/
|
||||
static void on_suggest_async_ready(GObject *source_object, GAsyncResult *res,
|
||||
gpointer user_data) {
|
||||
suggest_request_t *req = (suggest_request_t *)user_data;
|
||||
SoupSession *session = SOUP_SESSION(source_object);
|
||||
|
||||
char **suggestions = NULL;
|
||||
|
||||
GBytes *bytes = soup_session_send_and_read_finish(session, res, NULL);
|
||||
if (bytes != NULL) {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(bytes, &size);
|
||||
if (data && size > 0) {
|
||||
suggestions = parse_suggestions(data, size);
|
||||
}
|
||||
g_bytes_unref(bytes);
|
||||
}
|
||||
|
||||
/* Deliver on the main thread via an idle handler. We're already on
|
||||
* the main thread (libsoup-3.0 async callbacks run on the thread
|
||||
* that started the request), but the idle handler avoids re-entrancy
|
||||
* issues with the entry completion widget. */
|
||||
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
|
||||
delivery->callback = req->callback;
|
||||
delivery->suggestions = suggestions;
|
||||
delivery->user_data = req->user_data;
|
||||
g_idle_add(deliver_suggestions_idle, delivery);
|
||||
|
||||
/* Clean up. */
|
||||
g_active_requests = g_list_remove(g_active_requests, req);
|
||||
g_object_unref(req->msg);
|
||||
if (req->cancellable) g_object_unref(req->cancellable);
|
||||
g_object_unref(req->session);
|
||||
g_free(req);
|
||||
}
|
||||
|
||||
guint search_suggest_fetch_async(const char *query,
|
||||
search_suggest_callback callback,
|
||||
gpointer user_data) {
|
||||
if (query == NULL || query[0] == '\0' || callback == NULL) return 0;
|
||||
|
||||
const search_engine_t *engine = search_engine_get_active();
|
||||
if (engine->suggest_url == NULL) {
|
||||
/* Engine has no suggestion API — deliver NULL immediately. */
|
||||
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
|
||||
delivery->callback = callback;
|
||||
delivery->suggestions = NULL;
|
||||
delivery->user_data = user_data;
|
||||
g_idle_add(deliver_suggestions_idle, delivery);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build the suggestion URL. */
|
||||
char *encoded = g_uri_escape_string(query, NULL, FALSE);
|
||||
if (encoded == NULL) return 0;
|
||||
|
||||
char *url = g_strdup_printf(engine->suggest_url, encoded);
|
||||
g_free(encoded);
|
||||
if (url == NULL) return 0;
|
||||
|
||||
/* Create the request. */
|
||||
suggest_request_t *req = g_new0(suggest_request_t, 1);
|
||||
req->id = g_next_request_id++;
|
||||
req->callback = callback;
|
||||
req->user_data = user_data;
|
||||
req->cancellable = g_cancellable_new();
|
||||
|
||||
/* Create a SoupSession per request. Suggestion requests are
|
||||
* infrequent (one per keystroke with debouncing) and this avoids
|
||||
* thread-safety concerns with a shared session. */
|
||||
req->session = soup_session_new();
|
||||
g_object_set(req->session, "timeout", 5, "idle-timeout", 5, NULL);
|
||||
|
||||
req->msg = soup_message_new("GET", url);
|
||||
g_free(url);
|
||||
if (req->msg == NULL) {
|
||||
g_object_unref(req->cancellable);
|
||||
g_object_unref(req->session);
|
||||
g_free(req);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Set a User-Agent so the APIs don't block us. */
|
||||
soup_message_headers_replace(soup_message_get_request_headers(req->msg),
|
||||
"User-Agent",
|
||||
"sovereign_browser/1.0");
|
||||
|
||||
g_active_requests = g_list_prepend(g_active_requests, req);
|
||||
|
||||
/* Send the request asynchronously. on_suggest_async_ready is
|
||||
* invoked when the full response body has been downloaded. */
|
||||
soup_session_send_and_read_async(req->session, req->msg,
|
||||
G_PRIORITY_DEFAULT,
|
||||
req->cancellable,
|
||||
on_suggest_async_ready,
|
||||
req);
|
||||
|
||||
return req->id;
|
||||
}
|
||||
|
||||
void search_suggest_cancel(guint request_id) {
|
||||
if (request_id == 0) return;
|
||||
|
||||
GList *l = g_active_requests;
|
||||
while (l != NULL) {
|
||||
suggest_request_t *req = (suggest_request_t *)l->data;
|
||||
if (req->id == request_id) {
|
||||
g_cancellable_cancel(req->cancellable);
|
||||
return; /* The async callback will handle cleanup. */
|
||||
}
|
||||
l = l->next;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* search.h — search engine integration for sovereign_browser
|
||||
*
|
||||
* Provides search engine configuration (DuckDuckGo default, with Google,
|
||||
* Brave, Startpage, and Searx as alternatives), URL building from query
|
||||
* strings, async autocomplete suggestion fetching, and a URL-vs-query
|
||||
* heuristic for the URL bar.
|
||||
*
|
||||
* The active engine name is stored in settings ("search_engine" key) and
|
||||
* synced across devices via NIP-78.
|
||||
*/
|
||||
|
||||
#ifndef SEARCH_H
|
||||
#define SEARCH_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Search engine definitions ──────────────────────────────────────── */
|
||||
|
||||
#define SEARCH_ENGINE_NAME_MAX 64
|
||||
#define SEARCH_ENGINE_URL_MAX 512
|
||||
#define SEARCH_SUGGESTION_MAX 128 /* max suggestions per request */
|
||||
#define SEARCH_QUERY_MAX 1024
|
||||
|
||||
typedef struct {
|
||||
const char *id; /* short identifier, e.g. "duckduckgo" */
|
||||
const char *name; /* display name, e.g. "DuckDuckGo" */
|
||||
const char *search_url; /* URL template with %s for the query */
|
||||
const char *suggest_url; /* autocomplete API URL template, or NULL */
|
||||
} search_engine_t;
|
||||
|
||||
/*
|
||||
* Get the list of built-in search engines (NULL-terminated array).
|
||||
* The array is static and valid for the lifetime of the program.
|
||||
*/
|
||||
const search_engine_t *search_engines_get(void);
|
||||
|
||||
/*
|
||||
* Get the number of built-in search engines.
|
||||
*/
|
||||
int search_engines_count(void);
|
||||
|
||||
/*
|
||||
* Look up a search engine by its id string.
|
||||
* Returns the engine, or NULL if not found.
|
||||
*/
|
||||
const search_engine_t *search_engine_by_id(const char *id);
|
||||
|
||||
/*
|
||||
* Get the currently active search engine (from settings).
|
||||
* Always returns a valid engine — falls back to DuckDuckGo if the
|
||||
* configured engine is unknown or unset.
|
||||
*/
|
||||
const search_engine_t *search_engine_get_active(void);
|
||||
|
||||
/*
|
||||
* Set the active search engine by id. Persists to settings.
|
||||
* Returns 0 on success, -1 if the id is unknown.
|
||||
*/
|
||||
int search_engine_set_active(const char *id);
|
||||
|
||||
/* ── URL building ──────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Build a search results URL for the given query using the active engine.
|
||||
* query — the search query string (raw, will be URL-encoded)
|
||||
* Returns a newly allocated string (caller must g_free).
|
||||
*/
|
||||
char *search_build_search_url(const char *query);
|
||||
|
||||
/*
|
||||
* Build a search results URL for the given query using a specific engine.
|
||||
* Returns a newly allocated string (caller must g_free).
|
||||
*/
|
||||
char *search_build_search_url_for(const search_engine_t *engine,
|
||||
const char *query);
|
||||
|
||||
/* ── URL heuristic ─────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Determine whether the given input string looks like a URL rather than
|
||||
* a search query.
|
||||
*
|
||||
* Returns TRUE if:
|
||||
* - It contains "://" (has a scheme)
|
||||
* - It starts with "about:" (internal pages)
|
||||
* - It has no spaces AND contains a dot (looks like a domain)
|
||||
* - It's a valid-looking IPv4 address
|
||||
* - It's "localhost" or starts with "localhost:"
|
||||
*/
|
||||
gboolean search_is_url(const char *input);
|
||||
|
||||
/* ── Async suggestion fetch ────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Callback invoked when autocomplete suggestions have been fetched.
|
||||
* suggestions — NULL-terminated array of newly allocated strings,
|
||||
* or NULL on error / no suggestions
|
||||
* user_data — the pointer passed to search_suggest_fetch_async()
|
||||
*/
|
||||
typedef void (*search_suggest_callback)(char **suggestions,
|
||||
gpointer user_data);
|
||||
|
||||
/*
|
||||
* Asynchronously fetch autocomplete suggestions for the given query
|
||||
* from the active search engine's suggestion API.
|
||||
*
|
||||
* If the active engine has no suggestion URL, the callback is called
|
||||
* immediately with NULL. The callback is always invoked on the GTK
|
||||
* main thread (via g_idle_add), so it's safe to touch GTK widgets.
|
||||
*
|
||||
* query — the partial search query
|
||||
* callback — called with the results (or NULL on error)
|
||||
* user_data — passed to the callback
|
||||
*
|
||||
* Returns a guint request ID (can be used with search_suggest_cancel()),
|
||||
* or 0 if the request could not be started.
|
||||
*/
|
||||
guint search_suggest_fetch_async(const char *query,
|
||||
search_suggest_callback callback,
|
||||
gpointer user_data);
|
||||
|
||||
/*
|
||||
* Cancel a pending suggestion request by its ID.
|
||||
* Safe to call with 0 (no-op) or an already-completed request ID.
|
||||
*/
|
||||
void search_suggest_cancel(guint request_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SEARCH_H */
|
||||
+47
-65
@@ -1,71 +1,45 @@
|
||||
/*
|
||||
* 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.
|
||||
* Saves open tab URLs (and titles) to the SQLite database (session table)
|
||||
* on window close, and restores them on startup if settings.restore_session
|
||||
* is true.
|
||||
*/
|
||||
|
||||
#include "session.h"
|
||||
#include "settings.h"
|
||||
#include "tab_manager.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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();
|
||||
|
||||
/* Build arrays of URLs and titles. */
|
||||
const char **urls = g_new0(const char *, count);
|
||||
const char **titles = g_new0(const char *, count);
|
||||
|
||||
int valid = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *url = tab_manager_get_url(i);
|
||||
if (url != NULL && url[0] != '\0') {
|
||||
fprintf(f, "%s\n", url);
|
||||
urls[valid] = url;
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
titles[valid] = (tab && tab->title[0]) ? tab->title : "";
|
||||
valid++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
g_print("[session] Saved %d tab(s)\n", count);
|
||||
|
||||
db_session_save(urls, titles, valid);
|
||||
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
g_print("[session] Saved %d tab(s)\n", valid);
|
||||
}
|
||||
|
||||
/* ── Restore ──────────────────────────────────────────────────────── */
|
||||
@@ -76,32 +50,40 @@ int session_restore(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
if (session_path(path, sizeof(path)) != 0) {
|
||||
return 0;
|
||||
}
|
||||
char **urls = NULL;
|
||||
char **titles = NULL;
|
||||
int count = 0;
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
int rc = db_session_load(&urls, &titles, &count);
|
||||
if (rc < 0 || count == 0) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
if (titles) {
|
||||
for (int i = 0; i < count; i++) g_free(titles[i]);
|
||||
g_free(titles);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int restored = 0;
|
||||
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++;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (urls[i] && urls[i][0]) {
|
||||
int index = tab_manager_new_tab(urls[i]);
|
||||
if (index >= 0) {
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
/* Free the arrays. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
g_free(urls[i]);
|
||||
g_free(titles[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
|
||||
g_print("[session] Restored %d tab(s)\n", restored);
|
||||
return restored;
|
||||
|
||||
+236
-107
@@ -1,10 +1,13 @@
|
||||
/*
|
||||
* settings.c — browser preferences for sovereign_browser
|
||||
*
|
||||
* Persists settings to ~/.sovereign_browser/settings.conf as key=value lines.
|
||||
* Persists settings to the SQLite database (key_value table). Each setting
|
||||
* is stored as a key-value pair. The database must be initialized (db_init())
|
||||
* before settings_load() is called.
|
||||
*/
|
||||
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
@@ -12,10 +15,37 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
|
||||
/* ── Global.db path helper ─────────────────────────────────────────── *
|
||||
* Returns the path to ~/.sovereign_browser/global.db (creating the
|
||||
* directory if needed). Used by settings_save_global() to write global
|
||||
* settings to global.db via db_kv_set_to_file(), even when the per-user
|
||||
* browser.db is the main open database.
|
||||
*/
|
||||
static void global_db_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
n = snprintf(out, out_sz, "%s/global.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global singleton ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -33,30 +63,19 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
s->ctrl_tab_switch = TRUE;
|
||||
s->max_tabs = SETTINGS_MAX_TABS_DEFAULT;
|
||||
s->tab_drag_reorder = TRUE;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
s->agent_server_enabled = TRUE;
|
||||
s->agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins), "*");
|
||||
s->agent_login_timeout_ms = SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT;
|
||||
snprintf(s->bootstrap_relays, sizeof(s->bootstrap_relays), "%s",
|
||||
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
|
||||
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
|
||||
SETTINGS_SEARCH_ENGINE_DEFAULT);
|
||||
s->theme_dark = TRUE; /* default: dark mode */
|
||||
s->inspector_x = -1; /* -1 = let window manager decide */
|
||||
s->inspector_y = -1;
|
||||
s->inspector_w = -1;
|
||||
s->inspector_h = -1;
|
||||
}
|
||||
|
||||
/* ── Parsing helpers ──────────────────────────────────────────────── */
|
||||
@@ -96,100 +115,210 @@ static int parse_position(const char *value, int fallback) {
|
||||
return parse_int(value, fallback);
|
||||
}
|
||||
|
||||
/* ── Load / Save ──────────────────────────────────────────────────── */
|
||||
/* ── Position to string ───────────────────────────────────────────── */
|
||||
|
||||
static const char *position_to_string(int pos) {
|
||||
switch (pos) {
|
||||
case GTK_POS_TOP: return "top";
|
||||
case GTK_POS_BOTTOM: return "bottom";
|
||||
case GTK_POS_LEFT: return "left";
|
||||
case GTK_POS_RIGHT: return "right";
|
||||
default: return "top";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Load / Save (SQLite key_value table) ─────────────────────────── *
|
||||
* Settings are split into two groups:
|
||||
*
|
||||
* GLOBAL (stored in ~/.sovereign_browser/global.db):
|
||||
* agent_server_enabled, agent_server_port, agent_allowed_origins,
|
||||
* agent_login_timeout_ms, theme_dark, inspector_x/y/w/h
|
||||
*
|
||||
* PER-USER (stored in ~/.sovereign_browser/profiles/<pubkey>/browser.db):
|
||||
* restore_session, new_tab_url, tab_bar_position,
|
||||
* show_tab_close_buttons, middle_click_close, ctrl_tab_switch,
|
||||
* max_tabs, tab_drag_reorder, bootstrap_relays, search_engine
|
||||
*
|
||||
* The settings struct is a single in-memory singleton. Global settings
|
||||
* are loaded once at startup (from global.db) and kept in memory. Per-user
|
||||
* settings are loaded after login (from the per-user browser.db). Both
|
||||
* groups are saved when the user changes settings on the settings page.
|
||||
*/
|
||||
|
||||
void settings_load_global(void) {
|
||||
/* Read global settings from the currently-open database (global.db).
|
||||
* Defaults must already be set (call settings_set_defaults first, or
|
||||
* call settings_load_user() which sets defaults). */
|
||||
const char *val;
|
||||
|
||||
val = db_kv_get("agent_server_enabled");
|
||||
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
|
||||
|
||||
val = db_kv_get("agent_server_port");
|
||||
if (val) {
|
||||
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
|
||||
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
}
|
||||
|
||||
val = db_kv_get("agent_allowed_origins");
|
||||
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
|
||||
|
||||
val = db_kv_get("agent_login_timeout_ms");
|
||||
if (val) {
|
||||
g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms);
|
||||
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
|
||||
}
|
||||
|
||||
val = db_kv_get("theme_dark");
|
||||
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
|
||||
|
||||
val = db_kv_get("inspector_x");
|
||||
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
|
||||
val = db_kv_get("inspector_y");
|
||||
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
|
||||
val = db_kv_get("inspector_w");
|
||||
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
|
||||
val = db_kv_get("inspector_h");
|
||||
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
|
||||
}
|
||||
|
||||
void settings_load_user(void) {
|
||||
/* Set defaults first — this resets the entire struct, so global
|
||||
* settings loaded earlier would be lost. To preserve global settings,
|
||||
* the caller should call settings_load_global() AFTER this function
|
||||
* if both need to be (re)loaded. The normal startup flow is:
|
||||
* 1. settings_set_defaults() (via settings_load_user)
|
||||
* 2. settings_load_global() (from global.db)
|
||||
* 3. ... login ...
|
||||
* 4. settings_load_user() (from per-user browser.db, preserves
|
||||
* global settings already in memory)
|
||||
*
|
||||
* But settings_load_user() does NOT reset defaults — it only reads
|
||||
* per-user keys, leaving global keys untouched. So the correct flow
|
||||
* is:
|
||||
* 1. settings_set_defaults() — once
|
||||
* 2. settings_load_global() — from global.db
|
||||
* 3. ... login, switch to per-user db ...
|
||||
* 4. settings_load_user() — from per-user browser.db
|
||||
*/
|
||||
const char *val;
|
||||
|
||||
val = db_kv_get("restore_session");
|
||||
if (val) g_settings.restore_session = parse_bool(val, g_settings.restore_session);
|
||||
|
||||
val = db_kv_get("new_tab_url");
|
||||
if (val) snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), "%s", val);
|
||||
|
||||
val = db_kv_get("tab_bar_position");
|
||||
if (val) g_settings.tab_bar_position = parse_position(val, g_settings.tab_bar_position);
|
||||
|
||||
val = db_kv_get("show_tab_close_buttons");
|
||||
if (val) g_settings.show_tab_close_buttons = parse_bool(val, g_settings.show_tab_close_buttons);
|
||||
|
||||
val = db_kv_get("middle_click_close");
|
||||
if (val) g_settings.middle_click_close = parse_bool(val, g_settings.middle_click_close);
|
||||
|
||||
val = db_kv_get("ctrl_tab_switch");
|
||||
if (val) g_settings.ctrl_tab_switch = parse_bool(val, g_settings.ctrl_tab_switch);
|
||||
|
||||
val = db_kv_get("max_tabs");
|
||||
if (val) {
|
||||
g_settings.max_tabs = parse_int(val, g_settings.max_tabs);
|
||||
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
|
||||
}
|
||||
|
||||
val = db_kv_get("tab_drag_reorder");
|
||||
if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder);
|
||||
|
||||
val = db_kv_get("bootstrap_relays");
|
||||
if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val);
|
||||
|
||||
val = db_kv_get("search_engine");
|
||||
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
|
||||
}
|
||||
|
||||
void settings_load(void) {
|
||||
/* Backward-compatible full load: sets defaults, then loads all
|
||||
* settings from the currently-open database. Used when a single
|
||||
* database contains all settings (legacy or test scenarios). */
|
||||
settings_set_defaults(&g_settings);
|
||||
settings_load_global();
|
||||
settings_load_user();
|
||||
}
|
||||
|
||||
char path[512];
|
||||
if (settings_path(path, sizeof(path)) != 0) {
|
||||
void settings_save_global(void) {
|
||||
/* Save global settings to global.db. This uses db_kv_set_to_file()
|
||||
* which opens a separate short-lived connection, so it works even
|
||||
* when the per-user browser.db is the main open database (the normal
|
||||
* case after login). At startup, when global.db IS the main open
|
||||
* database, this also works (the separate connection is fine since
|
||||
* SQLite handles concurrent readers/writers with FULLMUTEX). */
|
||||
char gpath[512];
|
||||
global_db_path(gpath, sizeof(gpath));
|
||||
if (gpath[0] == '\0') {
|
||||
g_printerr("[settings] Failed to get global.db path for save\n");
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
char buf[32];
|
||||
|
||||
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;
|
||||
db_kv_set_to_file(gpath, "agent_server_enabled",
|
||||
g_settings.agent_server_enabled ? "true" : "false");
|
||||
|
||||
/* Split key=value. */
|
||||
char *eq = strchr(line, '=');
|
||||
if (eq == NULL) continue;
|
||||
*eq = '\0';
|
||||
char *key = line;
|
||||
char *value = eq + 1;
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
|
||||
db_kv_set_to_file(gpath, "agent_server_port", buf);
|
||||
|
||||
/* Trim whitespace from key. */
|
||||
while (*key == ' ' || *key == '\t') key++;
|
||||
char *kend = key + strlen(key);
|
||||
while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0';
|
||||
db_kv_set_to_file(gpath, "agent_allowed_origins",
|
||||
g_settings.agent_allowed_origins);
|
||||
|
||||
/* Trim whitespace from value. */
|
||||
while (*value == ' ' || *value == '\t') value++;
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
|
||||
db_kv_set_to_file(gpath, "agent_login_timeout_ms", buf);
|
||||
|
||||
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);
|
||||
}
|
||||
/* Unknown keys are silently ignored. */
|
||||
}
|
||||
fclose(f);
|
||||
db_kv_set_to_file(gpath, "theme_dark",
|
||||
g_settings.theme_dark ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
|
||||
db_kv_set_to_file(gpath, "inspector_x", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
|
||||
db_kv_set_to_file(gpath, "inspector_y", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
|
||||
db_kv_set_to_file(gpath, "inspector_w", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
|
||||
db_kv_set_to_file(gpath, "inspector_h", buf);
|
||||
}
|
||||
|
||||
void settings_save_user(void) {
|
||||
/* Save per-user settings to the currently-open database (should be
|
||||
* the per-user browser.db). Called when the user changes per-user
|
||||
* settings on the settings page. */
|
||||
char buf[32];
|
||||
|
||||
db_kv_set("restore_session", g_settings.restore_session ? "true" : "false");
|
||||
db_kv_set("new_tab_url", g_settings.new_tab_url);
|
||||
db_kv_set("tab_bar_position", position_to_string(g_settings.tab_bar_position));
|
||||
db_kv_set("show_tab_close_buttons", g_settings.show_tab_close_buttons ? "true" : "false");
|
||||
db_kv_set("middle_click_close", g_settings.middle_click_close ? "true" : "false");
|
||||
db_kv_set("ctrl_tab_switch", g_settings.ctrl_tab_switch ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.max_tabs);
|
||||
db_kv_set("max_tabs", buf);
|
||||
|
||||
db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false");
|
||||
|
||||
/* Bootstrap relays are stored as-is (newlines are fine in SQLite). */
|
||||
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
|
||||
|
||||
db_kv_set("search_engine", g_settings.search_engine);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
fclose(f);
|
||||
/* Backward-compatible full save: saves all settings to the
|
||||
* currently-open database. The settings page should call
|
||||
* settings_save_user() + settings_save_global() instead, so that
|
||||
* global settings go to global.db and per-user settings go to the
|
||||
* per-user browser.db. */
|
||||
settings_save_user();
|
||||
settings_save_global();
|
||||
}
|
||||
|
||||
const browser_settings_t *settings_get(void) {
|
||||
|
||||
+57
-5
@@ -15,9 +15,19 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com"
|
||||
#define SETTINGS_NEW_TAB_URL_DEFAULT ""
|
||||
#define SETTINGS_NEW_TAB_URL_MAX 512
|
||||
#define SETTINGS_MAX_TABS_DEFAULT 50
|
||||
#define SETTINGS_AGENT_PORT_DEFAULT 17777
|
||||
#define SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT 30000 /* ms */
|
||||
#define SETTINGS_AGENT_ORIGINS_MAX 256
|
||||
#define SETTINGS_BOOTSTRAP_RELAYS_MAX 2048
|
||||
#define SETTINGS_BOOTSTRAP_RELAYS_DEFAULT \
|
||||
"wss://laantungir.net/relay\n" \
|
||||
"wss://relay.primal.net\n" \
|
||||
"wss://relay.damus.io"
|
||||
#define SETTINGS_SEARCH_ENGINE_MAX 64
|
||||
#define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo"
|
||||
|
||||
typedef struct {
|
||||
gboolean restore_session; /* restore open tabs on next launch */
|
||||
@@ -28,20 +38,62 @@ typedef struct {
|
||||
gboolean ctrl_tab_switch; /* Ctrl+Tab cycles tabs */
|
||||
int max_tabs; /* maximum simultaneous tabs */
|
||||
gboolean tab_drag_reorder; /* allow drag to reorder tabs */
|
||||
gboolean agent_server_enabled; /* enable agent WebSocket server */
|
||||
int agent_server_port; /* port for agent server */
|
||||
char agent_allowed_origins[SETTINGS_AGENT_ORIGINS_MAX]; /* "*" for any */
|
||||
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
|
||||
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
|
||||
char search_engine[SETTINGS_SEARCH_ENGINE_MAX]; /* active search engine id */
|
||||
gboolean theme_dark; /* dark mode for sovereign:// pages */
|
||||
int inspector_x; /* inspector detached window X (-1 = default) */
|
||||
int inspector_y; /* inspector detached window Y (-1 = default) */
|
||||
int inspector_w; /* inspector detached window width (-1 = default) */
|
||||
int inspector_h; /* inspector detached window height (-1 = default) */
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
* Load settings from disk into the global singleton.
|
||||
* If the file doesn't exist or a key is missing, defaults are used.
|
||||
* Call once at startup.
|
||||
* Load all settings from the currently-open database into the global
|
||||
* singleton. Sets defaults first, then reads all keys.
|
||||
* Backward-compatible — prefer settings_load_global() + settings_load_user()
|
||||
* for the split-database flow.
|
||||
*/
|
||||
void settings_load(void);
|
||||
|
||||
/*
|
||||
* Save the current global settings to disk.
|
||||
* Load global settings (agent server, theme, inspector geometry) from
|
||||
* the currently-open database (should be global.db). Does NOT reset
|
||||
* defaults — call settings_set_defaults() first (or use the full
|
||||
* settings_load() if you need defaults reset).
|
||||
*/
|
||||
void settings_load_global(void);
|
||||
|
||||
/*
|
||||
* Load per-user settings (tabs, relays, search engine) from the
|
||||
* currently-open database (should be the per-user browser.db). Does NOT
|
||||
* reset defaults — only reads per-user keys, leaving global keys in the
|
||||
* struct untouched.
|
||||
*/
|
||||
void settings_load_user(void);
|
||||
|
||||
/*
|
||||
* Save all settings to the currently-open database.
|
||||
* Backward-compatible — prefer settings_save_global() + settings_save_user()
|
||||
* for the split-database flow.
|
||||
*/
|
||||
void settings_save(void);
|
||||
|
||||
/*
|
||||
* Save global settings to the currently-open database (should be
|
||||
* global.db). Call when global settings change.
|
||||
*/
|
||||
void settings_save_global(void);
|
||||
|
||||
/*
|
||||
* Save per-user settings to the currently-open database (should be the
|
||||
* per-user browser.db). Call when per-user settings change.
|
||||
*/
|
||||
void settings_save_user(void);
|
||||
|
||||
/*
|
||||
* Get a pointer to the global settings singleton.
|
||||
* Always valid after settings_load().
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs whitelisted, device-independent settings + keyboard shortcut
|
||||
* bindings across all devices the user logs in on. Uses a single kind
|
||||
* 30078 addressable event with d-tag "sovereign_browser". The content is
|
||||
* NIP-44 encrypted (self-to-self) JSON.
|
||||
*
|
||||
* The publish path reuses the same pattern as bookmarks.c's
|
||||
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
|
||||
* SQLite → publish to bootstrap relays.
|
||||
*
|
||||
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
|
||||
*/
|
||||
|
||||
#include "settings_sync.h"
|
||||
#include "settings.h"
|
||||
#include "shortcuts.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Global state ───────────────────────────────────────────────────── */
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static int g_have_signer = 0;
|
||||
|
||||
/* Debounce: a timeout source id for the deferred publish. */
|
||||
static guint g_publish_timeout_id = 0;
|
||||
|
||||
/* db_kv key for the last-synced timestamp. */
|
||||
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
|
||||
|
||||
/* ── Whitelist of syncable settings ─────────────────────────────────── */
|
||||
|
||||
/* Keys from the settings struct that should be synced. Device-specific
|
||||
* settings (agent port, allowed origins, session restore, security
|
||||
* toggles) are intentionally excluded. */
|
||||
static const char *g_sync_setting_keys[] = {
|
||||
"new_tab_url",
|
||||
"tab_bar_position",
|
||||
"show_tab_close_buttons",
|
||||
"middle_click_close",
|
||||
"ctrl_tab_switch", /* master shortcuts toggle */
|
||||
"tab_drag_reorder",
|
||||
"max_tabs",
|
||||
"bootstrap_relays",
|
||||
"search_engine",
|
||||
};
|
||||
static const int g_sync_setting_count =
|
||||
(int)(sizeof(g_sync_setting_keys) / sizeof(g_sync_setting_keys[0]));
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse bootstrap relays from settings into an array.
|
||||
* Returns the count. Fills urls_out (pointers into relay_buf).
|
||||
* Caller must g_free(relay_buf) after use. */
|
||||
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
*relay_buf = g_strdup(s->bootstrap_relays);
|
||||
int count = 0;
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && count < max) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
urls_out[count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Encrypt a plaintext string with NIP-44 (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *encrypt_content(const char *plaintext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
|
||||
char *ciphertext = NULL;
|
||||
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
|
||||
&ciphertext);
|
||||
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
|
||||
g_printerr("[settings_sync] NIP-44 encrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
/* Decrypt a NIP-44 ciphertext (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *decrypt_content(const char *ciphertext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
|
||||
|
||||
char *plaintext = NULL;
|
||||
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
|
||||
&plaintext);
|
||||
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
|
||||
g_printerr("[settings_sync] NIP-44 decrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/* ── Serialization ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Serialize all syncable settings + shortcuts to a JSON object:
|
||||
* {"settings":{...}, "shortcuts":{...}}
|
||||
* Returns a newly allocated cJSON object (caller must delete). */
|
||||
static cJSON *serialize_payload(void) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
/* Settings whitelist. */
|
||||
cJSON *settings_obj = cJSON_CreateObject();
|
||||
const browser_settings_t *s = settings_get();
|
||||
(void)s; /* read via db_kv_get to get string values uniformly */
|
||||
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
const char *key = g_sync_setting_keys[i];
|
||||
const char *val = db_kv_get(key);
|
||||
if (val) {
|
||||
cJSON_AddStringToObject(settings_obj, key, val);
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(root, "settings", settings_obj);
|
||||
|
||||
/* Shortcuts. */
|
||||
cJSON *shortcuts_obj = shortcuts_serialize();
|
||||
cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/* ── Publish (debounced) ────────────────────────────────────────────── */
|
||||
|
||||
/* The actual publish operation (no debounce). */
|
||||
static void do_publish(void) {
|
||||
if (!g_have_signer) return;
|
||||
|
||||
/* Serialize. */
|
||||
cJSON *payload = serialize_payload();
|
||||
char *json = cJSON_PrintUnformatted(payload);
|
||||
cJSON_Delete(payload);
|
||||
if (json == NULL) {
|
||||
g_printerr("[settings_sync] Failed to serialize payload\n");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Encrypt. */
|
||||
char *ciphertext = encrypt_content(json);
|
||||
free(json);
|
||||
if (ciphertext == NULL) return;
|
||||
|
||||
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(SETTINGS_SYNC_D_TAG));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
/* Create and sign the event. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
SETTINGS_SYNC_KIND, ciphertext, tags, g_signer, time(NULL));
|
||||
g_free(ciphertext);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[settings_sync] Failed to create/sign event\n");
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Record the sync timestamp. */
|
||||
char ts_buf[32];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%ld", (long)time(NULL));
|
||||
db_kv_set(SYNC_TS_KEY, ts_buf);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[settings_sync] Published kind %d to %d/%d relays\n",
|
||||
SETTINGS_SYNC_KIND, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[settings_sync] No relays configured, event stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
/* GLib timeout callback for the debounced publish. */
|
||||
static gboolean publish_timeout_cb(gpointer data) {
|
||||
(void)data;
|
||||
g_publish_timeout_id = 0;
|
||||
do_publish();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void settings_sync_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
settings_sync_init(signer, pubkey_hex);
|
||||
}
|
||||
|
||||
void settings_sync_publish(void) {
|
||||
if (!g_have_signer) return;
|
||||
|
||||
/* Cancel any pending publish and schedule a new one in 500ms. */
|
||||
if (g_publish_timeout_id > 0) {
|
||||
g_source_remove(g_publish_timeout_id);
|
||||
}
|
||||
g_publish_timeout_id = g_timeout_add(500, publish_timeout_cb, NULL);
|
||||
}
|
||||
|
||||
int settings_sync_merge_from_nostr(const void *event_cjson) {
|
||||
const cJSON *event = (const cJSON *)event_cjson;
|
||||
if (event == NULL) return -1;
|
||||
if (!g_have_signer) return -1;
|
||||
|
||||
/* Verify the kind. */
|
||||
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
if (!cJSON_IsNumber(kind) || (long)kind->valuedouble != SETTINGS_SYNC_KIND) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Verify the d-tag is "sovereign_browser". */
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return -1;
|
||||
|
||||
int is_ours = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
|
||||
cJSON_IsString(t1) &&
|
||||
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
|
||||
is_ours = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!is_ours) return -1;
|
||||
|
||||
/* Get the event's created_at. */
|
||||
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
if (!cJSON_IsNumber(created_at)) return -1;
|
||||
long event_ts = (long)created_at->valuedouble;
|
||||
|
||||
/* Compare to our last-synced timestamp. */
|
||||
const char *ts_str = db_kv_get(SYNC_TS_KEY);
|
||||
long local_ts = 0;
|
||||
if (ts_str) local_ts = atol(ts_str);
|
||||
|
||||
if (event_ts <= local_ts) {
|
||||
g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), "
|
||||
"skipping merge\n", event_ts, local_ts);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Decrypt the content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content)) return -1;
|
||||
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) return -1;
|
||||
|
||||
/* Parse the JSON payload. */
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (payload == NULL || !cJSON_IsObject(payload)) {
|
||||
g_printerr("[settings_sync] Failed to parse decrypted payload\n");
|
||||
if (payload) cJSON_Delete(payload);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Merge settings. */
|
||||
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
|
||||
if (cJSON_IsObject(settings_obj)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, settings_obj) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
/* Only merge whitelisted keys. */
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
|
||||
db_kv_set(item->string, item->valuestring);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Reload settings from db_kv into the in-memory singleton. */
|
||||
settings_load();
|
||||
}
|
||||
|
||||
/* Merge shortcuts. */
|
||||
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
|
||||
if (cJSON_IsObject(shortcuts_obj)) {
|
||||
shortcuts_merge_from_json(shortcuts_obj);
|
||||
}
|
||||
|
||||
/* Update the sync timestamp. */
|
||||
char ts_buf[32];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
|
||||
db_kv_set(SYNC_TS_KEY, ts_buf);
|
||||
|
||||
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
|
||||
event_ts);
|
||||
|
||||
cJSON_Delete(payload);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs user-configurable, device-independent settings across all devices
|
||||
* the user logs in on. Uses a single kind 30078 addressable event with
|
||||
* d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self)
|
||||
* JSON containing whitelisted settings + all keyboard shortcut bindings.
|
||||
*
|
||||
* Device-specific settings (agent port, allowed origins, session restore,
|
||||
* security toggles) are NOT synced — they stay local only.
|
||||
*
|
||||
* See plans/keyboard-shortcuts.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef SETTINGS_SYNC_H
|
||||
#define SETTINGS_SYNC_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* nostr_signer_t is needed for the init function */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The d-tag identifier used for our kind 30078 event. */
|
||||
#define SETTINGS_SYNC_D_TAG "sovereign_browser"
|
||||
|
||||
/* The Nostr kind for arbitrary custom app data (NIP-78). */
|
||||
#define SETTINGS_SYNC_KIND 30078
|
||||
|
||||
/*
|
||||
* Initialize the settings sync module. Stores the signer + pubkey
|
||||
* references for later publish/merge operations.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
|
||||
*
|
||||
* Call after login and after shortcuts_load() / settings_load().
|
||||
*/
|
||||
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Serialize all syncable settings + shortcuts, NIP-44 encrypt to self,
|
||||
* build and sign a kind 30078 event, publish to bootstrap relays, and
|
||||
* store in SQLite. Debounced internally (500ms) so rapid edits coalesce
|
||||
* into a single publish.
|
||||
*
|
||||
* Safe to call from the main thread. No-op if no signer is available
|
||||
* (read-only / no-login mode).
|
||||
*/
|
||||
void settings_sync_publish(void);
|
||||
|
||||
/*
|
||||
* Decrypt and merge settings from a fetched kind 30078 event.
|
||||
* Called by the relay fetch thread after login. Compares the event's
|
||||
* created_at to the last-synced timestamp; if the Nostr event is newer,
|
||||
* overwrites local db_kv values and reloads in-memory bindings.
|
||||
*
|
||||
* event — a cJSON object representing a kind 30078 event with
|
||||
* d-tag "sovereign_browser"
|
||||
*
|
||||
* Returns 0 on success, -1 on error / not applicable.
|
||||
*/
|
||||
int settings_sync_merge_from_nostr(const void *event_cjson);
|
||||
|
||||
/*
|
||||
* Update the signer reference (e.g. after switching identity).
|
||||
*/
|
||||
void settings_sync_set_signer(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SETTINGS_SYNC_H */
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* shortcuts.c — configurable keyboard shortcuts for sovereign_browser
|
||||
*
|
||||
* Bindings are stored in the SQLite key_value table as:
|
||||
* shortcut.<action_id> = "<GTK accelerator string>"
|
||||
* e.g. shortcut.new_tab = "<Control>t"
|
||||
*
|
||||
* On load, each stored string is parsed via gtk_accelerator_parse() into
|
||||
* a (keyval, mods) pair kept in a static array. shortcuts_lookup() does
|
||||
* a linear scan comparing incoming GdkEventKey values.
|
||||
*/
|
||||
|
||||
#include "shortcuts.h"
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
[SHORTCUT_NEW_TAB] = {
|
||||
"new_tab", "New tab",
|
||||
"Open a new tab", "<Control>t"
|
||||
},
|
||||
[SHORTCUT_CLOSE_TAB] = {
|
||||
"close_tab", "Close tab",
|
||||
"Close the active tab", "<Control>w"
|
||||
},
|
||||
[SHORTCUT_FOCUS_URL] = {
|
||||
"focus_url", "Focus URL bar",
|
||||
"Focus the URL entry of the active tab", "<Control>l"
|
||||
},
|
||||
[SHORTCUT_NEXT_TAB] = {
|
||||
"next_tab", "Next tab (Ctrl+Tab)",
|
||||
"Cycle to the next tab", "<Control>Tab"
|
||||
},
|
||||
[SHORTCUT_PREV_TAB] = {
|
||||
"prev_tab", "Previous tab (Ctrl+Shift+Tab)",
|
||||
"Cycle to the previous tab", "<Control><Shift>Tab"
|
||||
},
|
||||
[SHORTCUT_NEXT_TAB_PAGEDOWN] = {
|
||||
"next_tab_pagedown", "Next tab (PageDown)",
|
||||
"Switch to the next tab", "<Control>Page_Down"
|
||||
},
|
||||
[SHORTCUT_PREV_TAB_PAGEUP] = {
|
||||
"prev_tab_pageup", "Previous tab (PageUp)",
|
||||
"Switch to the previous tab", "<Control>Page_Up"
|
||||
},
|
||||
[SHORTCUT_RELOAD] = {
|
||||
"reload", "Reload page",
|
||||
"Reload the current page", "F5"
|
||||
},
|
||||
[SHORTCUT_FORCE_RELOAD] = {
|
||||
"force_reload", "Force reload",
|
||||
"Reload bypassing cache", "<Shift>F5"
|
||||
},
|
||||
[SHORTCUT_GO_BACK] = {
|
||||
"go_back", "Go back",
|
||||
"Navigate back in history", "<Alt>Left"
|
||||
},
|
||||
[SHORTCUT_GO_FORWARD] = {
|
||||
"go_forward", "Go forward",
|
||||
"Navigate forward in history", "<Alt>Right"
|
||||
},
|
||||
[SHORTCUT_FIND] = {
|
||||
"find", "Find in page",
|
||||
"Open the find bar", "<Control>f"
|
||||
},
|
||||
[SHORTCUT_OPEN_SETTINGS] = {
|
||||
"open_settings", "Open settings",
|
||||
"Open the sovereign://settings page", "<Control>comma"
|
||||
},
|
||||
[SHORTCUT_NEW_IDENTITY] = {
|
||||
"new_identity", "Switch identity",
|
||||
"Open the Nostr login dialog to switch identity",
|
||||
"<Control><Shift>u"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_FULLSCREEN] = {
|
||||
"toggle_fullscreen", "Toggle fullscreen",
|
||||
"Enter or exit fullscreen mode", "F11"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_INSPECTOR] = {
|
||||
"toggle_inspector", "Toggle inspector",
|
||||
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
|
||||
},
|
||||
};
|
||||
|
||||
/* ── In-memory parsed bindings ──────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
guint keyval;
|
||||
GdkModifierType mods;
|
||||
char accel_str[64]; /* current string, for UI + sync */
|
||||
} parsed_binding_t;
|
||||
|
||||
static parsed_binding_t g_parsed[SHORTCUT_COUNT];
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* db_kv key for an action: "shortcut.new_tab" */
|
||||
static void kv_key(shortcut_action_t a, char *buf, size_t buflen) {
|
||||
snprintf(buf, buflen, "shortcut.%s", g_registry[a].id);
|
||||
}
|
||||
|
||||
/* Parse an accelerator string into the g_parsed slot and update accel_str.
|
||||
* gtk_accelerator_parse() returns void in GTK3, so we check keyval==0
|
||||
* to detect a parse failure. */
|
||||
static int parse_and_store(shortcut_action_t a, const char *accel_str) {
|
||||
if (accel_str == NULL || accel_str[0] == '\0') return -1;
|
||||
|
||||
guint keyval = 0;
|
||||
GdkModifierType mods = 0;
|
||||
gtk_accelerator_parse(accel_str, &keyval, &mods);
|
||||
if (keyval == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_parsed[a].keyval = keyval;
|
||||
g_parsed[a].mods = mods;
|
||||
snprintf(g_parsed[a].accel_str, sizeof(g_parsed[a].accel_str),
|
||||
"%s", accel_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
void shortcuts_load(void) {
|
||||
/* Start with defaults. */
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
const char *dflt = g_registry[i].dflt;
|
||||
parse_and_store((shortcut_action_t)i, dflt);
|
||||
}
|
||||
|
||||
/* Override with stored values from db_kv. */
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
char key[64];
|
||||
kv_key((shortcut_action_t)i, key, sizeof(key));
|
||||
const char *val = db_kv_get(key);
|
||||
if (val && val[0]) {
|
||||
if (parse_and_store((shortcut_action_t)i, val) != 0) {
|
||||
/* Bad stored value — keep the default. */
|
||||
g_printerr("[shortcuts] Bad stored value for %s: %s\n",
|
||||
key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int shortcuts_lookup(GdkEventKey *event) {
|
||||
if (event == NULL) return -1;
|
||||
|
||||
/* Master toggle: if shortcuts are disabled, bail out. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
if (!s->ctrl_tab_switch) return -1;
|
||||
|
||||
guint event_mods = event->state & gtk_accelerator_get_default_mod_mask();
|
||||
|
||||
/* gtk_accelerator_parse() stores lowercase keyvals for letter keys,
|
||||
* but GdkEventKey delivers the uppercase keyval when Shift is held.
|
||||
* Normalize the event keyval to lowercase for comparison. */
|
||||
guint event_keyval = gdk_keyval_to_lower(event->keyval);
|
||||
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
if (g_parsed[i].keyval == 0) continue;
|
||||
if (g_parsed[i].keyval == event_keyval &&
|
||||
g_parsed[i].mods == event_mods) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *shortcuts_get(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
|
||||
return g_parsed[action].accel_str;
|
||||
}
|
||||
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return -1;
|
||||
if (accel_str == NULL) return -1;
|
||||
|
||||
if (parse_and_store(action, accel_str) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char key[64];
|
||||
kv_key(action, key, sizeof(key));
|
||||
db_kv_set(key, g_parsed[action].accel_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void shortcuts_reset(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return;
|
||||
shortcuts_set(action, g_registry[action].dflt);
|
||||
}
|
||||
|
||||
void shortcuts_reset_all(void) {
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
shortcuts_reset((shortcut_action_t)i);
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
|
||||
return &g_registry[action];
|
||||
}
|
||||
|
||||
int shortcuts_action_from_id(const char *id) {
|
||||
if (id == NULL) return -1;
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
if (strcmp(g_registry[i].id, id) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ── Serialization (for NIP-78 sync) ────────────────────────────────── */
|
||||
|
||||
cJSON *shortcuts_serialize(void) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
cJSON_AddStringToObject(obj, g_registry[i].id,
|
||||
g_parsed[i].accel_str);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
void shortcuts_merge_from_json(const cJSON *json) {
|
||||
if (json == NULL || !cJSON_IsObject(json)) return;
|
||||
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, json) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
int action = shortcuts_action_from_id(item->string);
|
||||
if (action < 0) continue; /* unknown key — ignore */
|
||||
|
||||
if (parse_and_store((shortcut_action_t)action,
|
||||
item->valuestring) == 0) {
|
||||
char key[64];
|
||||
kv_key((shortcut_action_t)action, key, sizeof(key));
|
||||
db_kv_set(key, g_parsed[action].accel_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* shortcuts.h — configurable keyboard shortcuts for sovereign_browser
|
||||
*
|
||||
* Browser-level keyboard shortcuts (new tab, close tab, focus URL, tab
|
||||
* cycling, reload, back/forward, find, settings, fullscreen) are
|
||||
* user-configurable. Bindings are stored in the SQLite key_value table
|
||||
* as "shortcut.<action_id>" = GTK accelerator string (e.g. "<Control>t")
|
||||
* and synced across devices via NIP-78 (kind 30078) by settings_sync.c.
|
||||
*
|
||||
* The on_key_press() handler in main.c calls shortcuts_lookup() on each
|
||||
* key event and dispatches the returned action.
|
||||
*/
|
||||
|
||||
#ifndef SHORTCUTS_H
|
||||
#define SHORTCUTS_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/* Forward declaration for cJSON (used in serialize/merge functions). */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Action registry ────────────────────────────────────────────────── */
|
||||
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
SHORTCUT_PREV_TAB,
|
||||
SHORTCUT_NEXT_TAB_PAGEDOWN,
|
||||
SHORTCUT_PREV_TAB_PAGEUP,
|
||||
SHORTCUT_RELOAD,
|
||||
SHORTCUT_FORCE_RELOAD,
|
||||
SHORTCUT_GO_BACK,
|
||||
SHORTCUT_GO_FORWARD,
|
||||
SHORTCUT_FIND,
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
/* Metadata for the settings UI. */
|
||||
typedef struct {
|
||||
const char *id; /* "new_tab" — used in db_kv key and JSON */
|
||||
const char *label; /* "New tab" — shown in the settings page */
|
||||
const char *desc; /* "Open a new tab" — short description */
|
||||
const char *dflt; /* "<Control>t" — default GTK accelerator */
|
||||
} shortcut_meta_t;
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Load all bindings from the SQLite key_value table into the in-memory
|
||||
* array. Missing keys use the defaults from the registry.
|
||||
* Call once at startup after db_init() and settings_load().
|
||||
*/
|
||||
void shortcuts_load(void);
|
||||
|
||||
/*
|
||||
* Look up which action a key event matches, or -1 if none.
|
||||
* Applies gtk_accelerator_get_default_mod_mask() to the event state
|
||||
* and compares against the parsed bindings.
|
||||
*
|
||||
* If the master "shortcuts_enabled" setting is false, always returns -1.
|
||||
*/
|
||||
int shortcuts_lookup(GdkEventKey *event);
|
||||
|
||||
/*
|
||||
* Get the current accelerator string for an action (e.g. for the
|
||||
* settings UI or for Nostr sync serialization). Returns a pointer to
|
||||
* an internal buffer valid until the next shortcuts_set() call.
|
||||
*/
|
||||
const char *shortcuts_get(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Set and persist a new binding for an action.
|
||||
* accel_str — must be parseable by gtk_accelerator_parse()
|
||||
* Returns 0 on success, -1 on parse failure.
|
||||
* Updates the in-memory array immediately and writes to db_kv.
|
||||
*/
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str);
|
||||
|
||||
/*
|
||||
* Reset an action to its default binding (and persist to db_kv).
|
||||
*/
|
||||
void shortcuts_reset(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Reset all actions to their default bindings (and persist to db_kv).
|
||||
*/
|
||||
void shortcuts_reset_all(void);
|
||||
|
||||
/*
|
||||
* Get the metadata (id, label, desc, default) for an action.
|
||||
* Returns a pointer to a static struct.
|
||||
*/
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Look up an action by its string id (e.g. "new_tab").
|
||||
* Returns the action enum, or -1 if not found.
|
||||
*/
|
||||
int shortcuts_action_from_id(const char *id);
|
||||
|
||||
/*
|
||||
* Serialize all current bindings to a JSON object suitable for NIP-78
|
||||
* sync. Returns a newly allocated cJSON object (caller must delete).
|
||||
* Format: {"new_tab":"<Control>t","close_tab":"<Control>w",...}
|
||||
*/
|
||||
cJSON *shortcuts_serialize(void);
|
||||
|
||||
/*
|
||||
* Merge bindings from a parsed JSON object (as produced by
|
||||
* shortcuts_serialize). For each key that matches a known action id,
|
||||
* parse the accelerator and update the in-memory binding + db_kv.
|
||||
* Unknown keys are silently ignored.
|
||||
*
|
||||
* json — a cJSON object mapping action id → accelerator string
|
||||
*/
|
||||
void shortcuts_merge_from_json(const cJSON *json);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SHORTCUTS_H */
|
||||
+1782
-45
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ typedef struct {
|
||||
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;
|
||||
@@ -119,6 +120,12 @@ void tab_manager_close_others(int 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).
|
||||
*/
|
||||
@@ -135,6 +142,24 @@ void tab_manager_reload(int index);
|
||||
*/
|
||||
void tab_manager_apply_settings(void);
|
||||
|
||||
/*
|
||||
* Set the user's avatar on the far left of the tab bar. Queries the
|
||||
* kind 0 profile from SQLite for the picture URL and downloads it
|
||||
* in a background thread. Falls back to a default icon if no picture
|
||||
* is available. Call after login (when the pubkey is known).
|
||||
*
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, or NULL for default)
|
||||
*/
|
||||
void tab_manager_set_avatar(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Toggle the WebKit Web Inspector for the active tab.
|
||||
* If the inspector is visible, it is closed; otherwise it is shown.
|
||||
* The inspector's detached window position and size are saved to
|
||||
* settings and restored on the next show.
|
||||
*/
|
||||
void tab_manager_toggle_inspector(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.4"
|
||||
#define SB_VERSION "v0.0.23"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 4
|
||||
#define SB_VERSION_PATCH 23
|
||||
|
||||
#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))
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MCP Button Test Page</title>
|
||||
<style>
|
||||
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
|
||||
button { display: block; margin: 10px 0; padding: 10px 20px; font-size: 16px; cursor: pointer; }
|
||||
#result { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Button Test Page</h1>
|
||||
|
||||
<button id="btn-simple" onclick="document.getElementById('result').textContent='Simple button clicked!'">Click Me</button>
|
||||
|
||||
<button id="btn-addclass" class="min-h-[60vh] inline-flex items-center" onclick="document.getElementById('result').textContent='Tailwind button clicked!'">Tailwind Button</button>
|
||||
|
||||
<button id="btn-react-like" data-testid="react-btn">React-like Button</button>
|
||||
|
||||
<div id="result">No button clicked yet.</div>
|
||||
|
||||
<div id="hidden-section" class="hidden">
|
||||
<p>This section appears after clicking the toggle button.</p>
|
||||
<button id="btn-in-hidden" onclick="alert('Hidden button clicked!')">Hidden Button</button>
|
||||
</div>
|
||||
|
||||
<button id="btn-toggle" onclick="var s=document.getElementById('hidden-section'); s.classList.toggle('hidden'); document.getElementById('result').textContent='Toggled hidden section'">Toggle Hidden Section</button>
|
||||
|
||||
<script>
|
||||
// React-like event handling (addEventListener, not onclick)
|
||||
document.getElementById('btn-react-like').addEventListener('click', function() {
|
||||
document.getElementById('result').textContent = 'React-like button clicked via addEventListener!';
|
||||
});
|
||||
|
||||
// Pointer event handling (like Radix UI)
|
||||
var btnPointer = document.createElement('button');
|
||||
btnPointer.id = 'btn-pointer';
|
||||
btnPointer.textContent = 'Pointer Event Button';
|
||||
btnPointer.addEventListener('pointerdown', function(e) {
|
||||
document.getElementById('result').textContent = 'Pointer down detected!';
|
||||
});
|
||||
btnPointer.addEventListener('click', function(e) {
|
||||
document.getElementById('result').textContent = 'Pointer button clicked!';
|
||||
});
|
||||
document.body.insertBefore(btnPointer, document.getElementById('result'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MCP Form Test Page</title>
|
||||
<style>
|
||||
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
|
||||
label { display: block; margin: 10px 0 4px; font-weight: bold; }
|
||||
input, textarea, select { display: block; width: 100%; max-width: 300px; padding: 8px; font-size: 14px; margin-bottom: 10px; }
|
||||
button { padding: 10px 20px; font-size: 16px; cursor: pointer; margin: 5px 0; }
|
||||
#output { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Form Test Page</h1>
|
||||
|
||||
<form id="test-form" onsubmit="event.preventDefault(); submitForm()">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" placeholder="Enter your name">
|
||||
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" placeholder="Enter your email">
|
||||
|
||||
<label for="message">Message:</label>
|
||||
<textarea id="message" name="message" rows="3" placeholder="Enter a message"></textarea>
|
||||
|
||||
<label for="color">Favorite Color:</label>
|
||||
<select id="color" name="color">
|
||||
<option value="">Choose...</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
</select>
|
||||
|
||||
<label for="agree">
|
||||
<input type="checkbox" id="agree" name="agree"> I agree to the terms
|
||||
</label>
|
||||
|
||||
<button type="submit">Submit Form</button>
|
||||
<button type="button" id="clear-btn">Clear</button>
|
||||
</form>
|
||||
|
||||
<div id="output">Form not submitted yet.</div>
|
||||
|
||||
<script>
|
||||
function submitForm() {
|
||||
var data = {
|
||||
name: document.getElementById('name').value,
|
||||
email: document.getElementById('email').value,
|
||||
message: document.getElementById('message').value,
|
||||
color: document.getElementById('color').value,
|
||||
agree: document.getElementById('agree').checked
|
||||
};
|
||||
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
document.getElementById('clear-btn').addEventListener('click', function() {
|
||||
document.getElementById('test-form').reset();
|
||||
document.getElementById('output').textContent = 'Form cleared.';
|
||||
});
|
||||
|
||||
// React-like controlled input simulation
|
||||
var reactInput = document.createElement('input');
|
||||
reactInput.type = 'text';
|
||||
reactInput.id = 'react-input';
|
||||
reactInput.placeholder = 'React-like controlled input';
|
||||
reactInput.setAttribute('data-testid', 'react-input');
|
||||
var reactLabel = document.createElement('label');
|
||||
reactLabel.textContent = 'React-like Input:';
|
||||
reactLabel.htmlFor = 'react-input';
|
||||
var form = document.getElementById('test-form');
|
||||
form.insertBefore(reactLabel, form.firstChild);
|
||||
form.insertBefore(reactInput, reactLabel.nextSibling);
|
||||
|
||||
// Track value via event listener (like React does)
|
||||
var reactValue = '';
|
||||
reactInput.addEventListener('input', function(e) {
|
||||
reactValue = e.target.value;
|
||||
});
|
||||
|
||||
var reactBtn = document.createElement('button');
|
||||
reactBtn.type = 'button';
|
||||
reactBtn.textContent = 'Show React Value';
|
||||
reactBtn.addEventListener('click', function() {
|
||||
document.getElementById('output').textContent = 'React input value: ' + reactValue;
|
||||
});
|
||||
form.appendChild(reactBtn);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Log into the wsb test account.
|
||||
|
||||
./browser.sh restart --login-method nsigner --nsigner-transport qrexec --nsigner-device nostr_signer
|
||||
Reference in New Issue
Block a user