Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775bf27ab0 | ||
|
|
f01a345b7c | ||
|
|
f91ff74138 | ||
|
|
8c75c74c38 | ||
|
|
372339aa35 |
@@ -0,0 +1,72 @@
|
||||
# Agent Instructions — sovereign_browser
|
||||
|
||||
## Browser Management
|
||||
|
||||
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never launch `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open.
|
||||
|
||||
```bash
|
||||
./browser.sh start # build + launch (detached, waits for MCP ready)
|
||||
./browser.sh stop # kill running instance
|
||||
./browser.sh restart # stop + build + start
|
||||
./browser.sh status # check if running + MCP responsive
|
||||
./browser.sh log # tail last 50 lines of browser log
|
||||
```
|
||||
|
||||
The browser's MCP server runs on `http://localhost:17777/mcp` (Streamable HTTP transport).
|
||||
|
||||
## Login
|
||||
|
||||
The browser requires Nostr login before any browser tools work. The quickest way to log in is with a random key:
|
||||
|
||||
```bash
|
||||
# Via MCP (curl):
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"random"}}}'
|
||||
```
|
||||
|
||||
Or use the `login` MCP tool with `{"method": "random"}`. This generates a fresh random Nostr keypair and logs in immediately. The response includes the `nsec` so you can save it if needed.
|
||||
|
||||
Other login methods: `local` (nsec/hex), `seed` (mnemonic), `readonly` (npub), `nip46` (bunker URL), `nsigner` (hardware signer).
|
||||
|
||||
## MCP Server
|
||||
|
||||
The browser exposes 100 MCP tools for browser automation:
|
||||
|
||||
- **Login tools**: `login_status`, `login`, `logout`, `switch_identity`
|
||||
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
|
||||
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`, `eval`, `screenshot`, `screenshot_annotated`
|
||||
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`, `hover`, `focus`, `select`, `check`, `uncheck`, `drag`, `keyboard_type`, `insert_text`, `keydown`, `keyup`, `scroll_into_view`
|
||||
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`, `find_alt`, `find_title`, `find_testid`, `find_first`, `find_last`, `find_nth`
|
||||
- **Get info**: `get_value`, `get_count`, `get_box`, `get_styles`
|
||||
- **Check state**: `is_visible`, `is_enabled`, `is_checked`
|
||||
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
|
||||
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`, `wait_for_fn`
|
||||
- **Batch**: `batch` (execute multiple tool calls in sequence)
|
||||
- **Cookies & storage**: `cookies_get`, `cookies_set`, `cookies_clear`, `storage_local_*`, `storage_session_*`
|
||||
- **Mouse**: `mouse_move`, `mouse_down`, `mouse_up`, `mouse_wheel`
|
||||
- **Clipboard**: `clipboard_read`, `clipboard_write`, `clipboard_copy`, `clipboard_paste`
|
||||
- **Settings**: `set_viewport`, `set_media`, `set_offline`, `set_headers`, `set_credentials`
|
||||
- **Frames**: `frame_switch`, `frame_main`
|
||||
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
|
||||
- **Debug**: `console`, `errors`, `highlight`
|
||||
- **State**: `state_save`, `state_load`
|
||||
- **Files**: `upload`, `pdf`
|
||||
|
||||
## Common Workflow
|
||||
|
||||
1. `./browser.sh start` (or `restart` if already running)
|
||||
2. Login: call `login` tool with `{"method": "random"}`
|
||||
3. Navigate: call `open` tool with a URL
|
||||
4. Snapshot: call `snapshot` to get the accessibility tree with element refs
|
||||
5. Interact: use refs (e.g. `@e1`) with `click`, `fill`, etc.
|
||||
6. Screenshot: call `screenshot` to see the page visually
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make # build the browser
|
||||
make clean # clean build artifacts
|
||||
```
|
||||
|
||||
The browser is a WebKitGTK + C99 application. It links against `webkit2gtk-4.1`, `libsoup-3.0`, and `nostr_core_lib` (vendored).
|
||||
@@ -0,0 +1,50 @@
|
||||
# sovereign_browser — Agent Rules
|
||||
|
||||
## Browser Management (CRITICAL)
|
||||
|
||||
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never run `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open, which blocks the command runner.
|
||||
|
||||
```bash
|
||||
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
|
||||
./browser.sh stop # kill running instance
|
||||
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
|
||||
./browser.sh status # check if running + MCP responsive
|
||||
./browser.sh log # tail last 50 lines of browser log
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
|
||||
|
||||
```bash
|
||||
# Generate a fresh key and log in immediately — no credentials needed
|
||||
./browser.sh start --login-method generate
|
||||
|
||||
# Generate a key and open a URL in one command
|
||||
./browser.sh start --login-method generate --url https://example.com
|
||||
|
||||
# Log in with a local key (nsec)
|
||||
./browser.sh start --login-method local --nsec nsec1...
|
||||
|
||||
# Read-only mode (npub, no signing)
|
||||
./browser.sh start --login-method readonly --npub npub1...
|
||||
```
|
||||
|
||||
Other login methods: `seed` (BIP-39 mnemonic), `nip46` (bunker:// URL), `nsigner` (hardware signer). Run `./sovereign_browser --help` for the full flag list.
|
||||
|
||||
Alternatively, you can still log in at runtime via the MCP `login` tool with `{"method": "generate"}` (or `local`, `seed`, `readonly`, `nip46`, `nsigner`).
|
||||
|
||||
## MCP Server
|
||||
|
||||
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
|
||||
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
|
||||
- See `.roo/agents.md` for the full tool list and workflow
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make # build
|
||||
make clean # clean
|
||||
```
|
||||
|
||||
WebKitGTK + C99. Links `webkit2gtk-4.1`, `libsoup-3.0`, `nostr_core_lib` (vendored).
|
||||
@@ -6,18 +6,18 @@
|
||||
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
||||
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0)
|
||||
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 libsoup-3.0)
|
||||
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/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c
|
||||
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c
|
||||
|
||||
$(BIN): $(SRC) $(NOSTR_LIB)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
|
||||
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
|
||||
+64
-11
@@ -441,7 +441,7 @@ This means the agent server has two states:
|
||||
| Clipboard | 4 | 0 | 0 | 4 | 0 |
|
||||
| Mouse | 4 | 0 | 0 | 4 | 0 |
|
||||
| Settings | 7 | 0 | 0 | 4 | 3 |
|
||||
| Cookies/storage | 8 | 0 | 0 | 8 | 0 |
|
||||
| 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 |
|
||||
@@ -452,19 +452,24 @@ This means the agent server has two states:
|
||||
| Setup | 2 | 0 | 0 | 0 | 2 |
|
||||
| Auth | 2 | 0 | 0 | 0 | 2 |
|
||||
| Sessions | 4 | 0 | 0 | 2 | 2 |
|
||||
| **Total** | **119 + 8** | **31** | **0** | **67** | **29** |
|
||||
| **Total** | **119 + 8** | **30** | **0** | **70** | **29** |
|
||||
|
||||
**Phase 1 (31 tools):** Login tools (8) + the minimal set for agent-driven
|
||||
browser automation (23) — login, navigate, snapshot, inspect, interact,
|
||||
and manage tabs. 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 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 (67 tools):** Everything else that has a WebKitGTK equivalent.
|
||||
Deferred until Phase 1 is stable and tested with real agents.
|
||||
**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
|
||||
@@ -641,11 +646,21 @@ Add `libsoup-3.0` to pkg-config if not already included.
|
||||
15. Better snapshot formatting and error messages
|
||||
16. Edge case handling (page navigation during interaction, stale refs)
|
||||
|
||||
### Phase 3 — Extended tool catalog
|
||||
### Phase 3 — Extended tool catalog (COMPLETE)
|
||||
|
||||
17. All remaining tools from the comparison table (67 tools)
|
||||
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
|
||||
|
||||
@@ -734,3 +749,41 @@ 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
|
||||
+51
-3
@@ -56,7 +56,7 @@ This is ideal for us — we already have a SoupServer running.
|
||||
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
|
||||
```
|
||||
|
||||
4. **Server responds** with the full tool catalog (31 tools).
|
||||
4. **Server responds** with the full tool catalog (30 tools).
|
||||
|
||||
5. **Client sends `tools/call`**:
|
||||
```json
|
||||
@@ -238,9 +238,16 @@ void agent_mcp_register(SoupServer *server);
|
||||
The MCP handler at `/mcp` processes HTTP POST requests with JSON-RPC
|
||||
bodies. It handles three methods:
|
||||
|
||||
1. **`initialize`** — returns server info and capabilities
|
||||
2. **`tools/list`** — returns the tool catalog (all 31 tools with schemas)
|
||||
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
|
||||
@@ -313,3 +320,44 @@ configuration:
|
||||
5. Register the MCP handler in `agent_server_start()`
|
||||
6. Test with Roo Code or a manual MCP client
|
||||
7. Update Makefile and docs
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Completed** — all items in the implementation order above are done.
|
||||
|
||||
### What's implemented
|
||||
|
||||
- **MCP Streamable HTTP transport** on `/mcp` (port 17777), coexisting with the existing `/agent` WebSocket endpoint.
|
||||
- **Session management**: `initialize` creates a session and returns a `Mcp-Session-Id` header. Subsequent requests are validated against the session store. Unknown/expired sessions return 404. Sessions have a 60-second idle timeout.
|
||||
- **SSE responses**: all JSON-RPC responses (requests with an `id`) are wrapped in `event: message\ndata: <json>\n\n` SSE format with `Content-Type: text/event-stream`.
|
||||
- **GET /mcp**: returns a `text/event-stream` response with a `: connected` comment (satisfies clients probing for SSE push support).
|
||||
- **DELETE /mcp**: terminates a session by `Mcp-Session-Id` header. Returns 200 on success, 400 if header missing, 404 if session unknown.
|
||||
- **Notifications**: `notifications/*` and any request without an `id` return 202 Accepted with no body.
|
||||
- **Tool catalog**: 30 tools exposed (29 original + `screenshot`). The `screenshot` tool returns an MCP image content block (`type: "image"`, base64 PNG, `mimeType: "image/png"`) instead of a text block.
|
||||
- **Lenient mode**: requests without a `Mcp-Session-Id` header are accepted with a warning (backward compatibility with older clients).
|
||||
- **Roo Code integration**: configured in `mcp_settings.json` with `transport: "streamable-http"` and all 30 tools in `alwaysAllow`.
|
||||
|
||||
### Test results (2026-07-12)
|
||||
|
||||
All 10 integration tests pass:
|
||||
|
||||
| Test | Description | Result |
|
||||
|------|-------------|--------|
|
||||
| 1 | Initialize — returns `Mcp-Session-Id` header + SSE body | ✅ Pass |
|
||||
| 2 | `tools/list` — 30 tools including `screenshot` | ✅ Pass |
|
||||
| 3 | `ping` with session — SSE empty result | ✅ Pass |
|
||||
| 4 | `resources/list` — empty resources array | ✅ Pass |
|
||||
| 5 | `prompts/list` — empty prompts array | ✅ Pass |
|
||||
| 6 | `notifications/initialized` — 202 Accepted | ✅ Pass |
|
||||
| 7 | `GET /mcp` — `text/event-stream` content type | ✅ Pass |
|
||||
| 8 | `DELETE /mcp` — 200 OK | ✅ Pass |
|
||||
| 9 | `ping` after DELETE — 404 (session terminated) | ✅ Pass |
|
||||
| 10 | `login_status` tool call — returns login state | ✅ Pass |
|
||||
|
||||
### Files
|
||||
|
||||
- [`src/agent_mcp.c`](src/agent_mcp.c) — MCP handler (request routing, session management, SSE responses)
|
||||
- [`src/agent_mcp.h`](src/agent_mcp.h) — public API
|
||||
- [`src/agent_tools.c`](src/agent_tools.c) — tool dispatch (shared with WebSocket endpoint)
|
||||
- [`src/agent_server.c`](src/agent_server.c) — registers MCP handler on the SoupServer
|
||||
- [`Makefile`](Makefile) — includes `src/agent_mcp.c` in the build
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Phase 3 — Extended Tool Catalog (70 tools)
|
||||
|
||||
> **Status: Complete** — All 8 batches implemented, built, and verified.
|
||||
> 100 total tools (30 Phase 1 + 70 Phase 3). See the verification table
|
||||
> at the bottom and [`plans/agent-tools.md`](agent-tools.md) for the full
|
||||
> status.
|
||||
|
||||
## Overview
|
||||
|
||||
Implement all remaining tools from the agent-browser comparison table.
|
||||
The original plan counted 67 tools; the final implementation delivered 70
|
||||
(11+7+10+5+11+13+10+3). The difference of 3 comes from expanding the
|
||||
storage category: the plan listed 8 local-only storage tools, but we
|
||||
added 4 `storage_session_*` tools (get, get_key, set, clear) for full
|
||||
session storage parity, bringing the storage batch to 11. These extend
|
||||
sovere_browser's MCP server from 30 tools to 100 tools, covering the full
|
||||
agent-browser feature set that has a WebKitGTK equivalent.
|
||||
|
||||
## Implementation patterns
|
||||
|
||||
All Phase 3 tools follow one of these patterns:
|
||||
|
||||
### Pattern A: JS eval (sync)
|
||||
The majority of tools. Resolve ref/selector → build JS string → `agent_js_eval_sync()` → parse result → return cJSON. Same pattern as existing `click`, `fill`, `get_text`, etc.
|
||||
|
||||
### Pattern B: WebKitGTK C API
|
||||
Some tools need WebKitGTK C APIs instead of JS:
|
||||
- Cookies → `WebKitCookieManager`
|
||||
- Settings → `WebKitSettings`
|
||||
- Dialogs → `WebKitScriptDialog` (via signal handler)
|
||||
- Console → `WebKitConsoleMessage` (via signal handler)
|
||||
- PDF → `webkit_web_view_save()` / print API
|
||||
- State → `WebKitWebsiteDataManager`
|
||||
|
||||
### Pattern C: GDK/GTK API
|
||||
- Clipboard → `GtkClipboard` / `GdkClipboard`
|
||||
- Mouse events → `gdk_event_new()` + `gtk_main_do_event()` (synthesized)
|
||||
- Viewport → `gtk_window_resize()`
|
||||
|
||||
### Pattern D: Composite
|
||||
- `batch` — calls `agent_tools_dispatch()` in a loop
|
||||
- `find_*` — builds CSS selector from semantic criteria, then uses Pattern A
|
||||
- `wait_for_*` — polls with `agent_js_eval_sync()` in a loop (like existing `wait_for`)
|
||||
|
||||
## Batches
|
||||
|
||||
### Batch 1: Extended interaction (11 tools)
|
||||
All Pattern A (JS eval). Straightforward extensions of existing interaction tools.
|
||||
|
||||
| Tool | Params | JS approach | Notes |
|
||||
|------|--------|-------------|-------|
|
||||
| `dblclick` | ref/selector | `el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))` | Like click but dblclick event |
|
||||
| `select` | ref/selector, value | Set `<select>` `.value` + dispatch change event | For dropdowns |
|
||||
| `check` | ref/selector | `el.checked = true; el.dispatchEvent(new Event('change'))` | Checkboxes |
|
||||
| `uncheck` | ref/selector | `el.checked = false; el.dispatchEvent(new Event('change'))` | Checkboxes |
|
||||
| `scroll_into_view` | ref/selector | `el.scrollIntoView({behavior:'smooth',block:'center'})` | Scroll element into view |
|
||||
| `keyboard_type` | value | Synthesize keydown+keypress+keyup per char on focused element | Real keystrokes |
|
||||
| `insert_text` | value | `document.execCommand('insertText', false, text)` | Insert without key events |
|
||||
| `keydown` | key | `new KeyboardEvent('keydown',{key:key,bubbles:true})` | Hold key down |
|
||||
| `keyup` | key | `new KeyboardEvent('keyup',{key:key,bubbles:true})` | Release key |
|
||||
| `drag` | src_ref/selector, tgt_ref/selector | Synthesize dragstart→dragenter→dragover→drop→dragend | HTML5 drag and drop |
|
||||
| `close_all` | (none) | Close all tabs via `tab_manager_close_all()` | New tab_manager function needed |
|
||||
|
||||
### Batch 2: Get info + check state (7 tools)
|
||||
All Pattern A (JS eval). Read-only queries returning element state.
|
||||
|
||||
| Tool | Params | JS approach | Returns |
|
||||
|------|--------|-------------|---------|
|
||||
| `get_value` | ref/selector | `el.value` | `{"value":"..."}` |
|
||||
| `get_count` | selector | `document.querySelectorAll(sel).length` | `{"count":N}` |
|
||||
| `get_box` | ref/selector | `el.getBoundingClientRect()` | `{"x":N,"y":N,"width":N,"height":N}` |
|
||||
| `get_styles` | ref/selector | `getComputedStyle(el)` → JSON | `{"styles":{...}}` |
|
||||
| `is_visible` | ref/selector | Check offsetParent, computed display/visibility | `{"visible":true/false}` |
|
||||
| `is_enabled` | ref/selector | `!el.disabled` | `{"enabled":true/false}` |
|
||||
| `is_checked` | ref/selector | `el.checked` | `{"checked":true/false}` |
|
||||
|
||||
### Batch 3: Find elements (10 tools)
|
||||
Pattern D (composite). Build CSS selector from semantic criteria, then query.
|
||||
|
||||
| Tool | Params | Selector approach |
|
||||
|------|--------|-------------------|
|
||||
| `find_role` | role, name? | `[role="X"]` or semantic HTML (button, nav, etc.) |
|
||||
| `find_text` | text, exact? | `:contains()` via JS (no native CSS) — walk DOM |
|
||||
| `find_label` | label | `label[for]` matching + `aria-label` + `aria-labelledby` |
|
||||
| `find_placeholder` | placeholder | `[placeholder*="X"]` |
|
||||
| `find_alt` | alt | `[alt*="X"]` |
|
||||
| `find_title` | title | `[title*="X"]` |
|
||||
| `find_testid` | testid | `[data-testid="X"]` |
|
||||
| `find_first` | selector | `document.querySelector(sel)` |
|
||||
| `find_last` | selector | `document.querySelectorAll(sel)[last]` |
|
||||
| `find_nth` | n, selector | `document.querySelectorAll(sel)[n]` |
|
||||
|
||||
All return `{"ref":"@eN","selector":"...","role":"...","name":"..."}` — assigns a ref so the result can be used with click/fill/etc. This requires integration with the snapshot ref system (`window.__agentRefs`).
|
||||
|
||||
### Batch 4: Wait + batch (5 tools)
|
||||
|
||||
| Tool | Params | Approach | Pattern |
|
||||
|------|--------|----------|---------|
|
||||
| `wait_for_text` | text, timeout? | Poll JS: `document.body.innerText.includes(text)` | D (poll) |
|
||||
| `wait_for_url` | url_pattern, timeout? | Poll `webkit_web_view_get_uri()` against regex | D (poll) |
|
||||
| `wait_for_load` | state?, timeout? | Wait for WebKitWebView load event | B (signal) |
|
||||
| `wait_for_fn` | script, timeout? | Poll JS: eval user script, check truthy | D (poll) |
|
||||
| `batch` | commands[] | Loop: call `agent_tools_dispatch()` per command | D (composite) |
|
||||
|
||||
### Batch 5: Cookies + storage (11 tools)
|
||||
Pattern B (WebKit API) for cookies, Pattern A (JS eval) for storage.
|
||||
|
||||
| Tool | Params | Approach | Pattern |
|
||||
|------|--------|----------|---------|
|
||||
| `cookies_get` | (none) | `WebKitCookieManager` async API | B |
|
||||
| `cookies_set` | name, value, domain, path, ... | JS: `document.cookie = "..."` | A |
|
||||
| `cookies_clear` | (none) | `webkit_cookie_manager_clear()` | B |
|
||||
| `storage_local_get` | (none) | JS: `JSON.stringify(localStorage)` | A |
|
||||
| `storage_local_get_key` | key | JS: `localStorage.getItem(key)` | A |
|
||||
| `storage_local_set` | key, value | JS: `localStorage.setItem(key, value)` | A |
|
||||
| `storage_local_clear` | (none) | JS: `localStorage.clear()` | A |
|
||||
| `storage_session_get` | (none) | JS: `JSON.stringify(sessionStorage)` | A |
|
||||
| `storage_session_get_key` | key | JS: `sessionStorage.getItem(key)` | A |
|
||||
| `storage_session_set` | key, value | JS: `sessionStorage.setItem(key, value)` | A |
|
||||
| `storage_session_clear` | (none) | JS: `sessionStorage.clear()` | A |
|
||||
|
||||
Note: The original plan collapsed `storage_session_*` into a single row
|
||||
and counted 8 total for the batch (3 cookies + 4 local + 1 session row).
|
||||
The implementation expanded the session row into 4 concrete tools, giving
|
||||
11 total (3 cookies + 4 local + 4 session). This is the source of the
|
||||
+3 difference between the planned 67 and the implemented 70.
|
||||
|
||||
### Batch 6: Mouse + clipboard + settings (12 tools)
|
||||
|
||||
| Tool | Params | Approach | Pattern |
|
||||
|------|--------|----------|---------|
|
||||
| `mouse_move` | x, y | JS: dispatch mousemove at coordinates | A |
|
||||
| `mouse_down` | button? | JS: dispatch mousedown | A |
|
||||
| `mouse_up` | button? | JS: dispatch mouseup | A |
|
||||
| `mouse_wheel` | dy, dx? | JS: dispatch wheel event | A |
|
||||
| `clipboard_read` | (none) | `gdk_clipboard_read_text_async()` | C |
|
||||
| `clipboard_write` | text | `gdk_clipboard_set_text()` | C |
|
||||
| `clipboard_copy` | (none) | JS: `document.execCommand('copy')` | A |
|
||||
| `clipboard_paste` | (none) | JS: `document.execCommand('paste')` | A |
|
||||
| `set_viewport` | width, height | `gtk_window_resize()` | C |
|
||||
| `set_offline` | on/off | `WebKitWebContext` network policy | B |
|
||||
| `set_headers` | headers_json | `WebKitUserContentManager` injection or custom | B |
|
||||
| `set_credentials` | user, pass | `WebKitAuthenticationRequest` handler | B |
|
||||
| `set_media` | dark/light | `WebKitSettings` + CSS media query emulation | B |
|
||||
|
||||
Note: set_media is 1 tool, set_credentials is 1 tool. Total for this batch: 13 tools (mouse 4 + clipboard 4 + settings 5).
|
||||
|
||||
### Batch 7: Frames + dialogs + debug (10 tools)
|
||||
|
||||
| Tool | Params | Approach | Pattern |
|
||||
|------|--------|----------|---------|
|
||||
| `frame_switch` | ref/selector | Track current frame in C, eval JS in frame context | B |
|
||||
| `frame_main` | (none) | Reset to main frame | B |
|
||||
| `dialog_accept` | text? | `webkit_script_dialog_confirm_set_confirmed()` etc. | B |
|
||||
| `dialog_dismiss` | (none) | Same API, set false | B |
|
||||
| `dialog_status` | (none) | Check if a script dialog is pending | B |
|
||||
| `console` | (none) | Collect console messages (need signal handler) | B |
|
||||
| `errors` | (none) | Collect page errors (need signal handler) | B |
|
||||
| `highlight` | ref/selector | JS: add temporary outline/border | A |
|
||||
| `state_save` | (none) | `WebKitWebsiteDataManager` persist | B |
|
||||
| `state_load` | (none) | Restore from persisted data | B |
|
||||
|
||||
Note: state_save/load/list = 3 tools. Total: 10 tools (frames 2 + dialogs 3 + debug 2 + highlight 1 + state 2).
|
||||
|
||||
### Batch 8: Complex tools (3 tools)
|
||||
|
||||
| Tool | Params | Approach | Pattern |
|
||||
|------|--------|----------|---------|
|
||||
| `upload` | ref/selector, files[] | Set file input `.files` via JS (limited) or WebKit API | B/A |
|
||||
| `pdf` | path? | `webkit_web_view_save()` or print-to-PDF API | B |
|
||||
| `screenshot_annotated` | (none) | Screenshot + overlay element labels via JS canvas | A+B |
|
||||
|
||||
## Tool count verification
|
||||
|
||||
| Batch | Tools | Count | Status |
|
||||
|-------|-------|-------|--------|
|
||||
| 1 | Extended interaction | 11 | ✅ Complete |
|
||||
| 2 | Get info + check state | 7 | ✅ Complete |
|
||||
| 3 | Find elements | 10 | ✅ Complete |
|
||||
| 4 | Wait + batch | 5 | ✅ Complete |
|
||||
| 5 | Cookies + storage | 11 | ✅ Complete (was 8 in plan; +3 `storage_session_*`) |
|
||||
| 6 | Mouse + clipboard + settings | 13 | ✅ Complete |
|
||||
| 7 | Frames + dialogs + debug | 10 | ✅ Complete |
|
||||
| 8 | Complex | 3 | ✅ Complete |
|
||||
| **Total** | | **70** | ✅ All batches complete |
|
||||
|
||||
### Final verification (post-implementation)
|
||||
|
||||
- `make clean && make` — builds cleanly (only pre-existing warnings in
|
||||
`login_dialog.c` and `agent_mcp.c`).
|
||||
- `tools/list` returns exactly **100 tools** (30 Phase 1 + 70 Phase 3).
|
||||
- `initialize` returns a session ID; `ping` returns an empty result.
|
||||
- `login_status` works without login (`logged_in: false`).
|
||||
- Browser tools (e.g. `dblclick`) return `NOT_LOGGED_IN` before login.
|
||||
- `batch` dispatches without login; sub-commands enforce login
|
||||
individually (bug fix: `batch` was incorrectly gated by login — added
|
||||
to the login exception list in `agent_tools_dispatch()`).
|
||||
- Roo Code MCP config `alwaysAllow` updated with all 100 tool names.
|
||||
|
||||
## Implementation order
|
||||
|
||||
Batches 1-4 are all Pattern A (JS eval) or simple composites — no new WebKit API knowledge needed. They should be done first.
|
||||
|
||||
Batches 5-8 require WebKitGTK C API knowledge and are more complex. They should be done after the JS-based tools are stable.
|
||||
|
||||
### Recommended execution order
|
||||
|
||||
1. **Batch 1** (11 tools) — extended interaction, all JS eval
|
||||
2. **Batch 2** (7 tools) — get info + check state, all JS eval
|
||||
3. **Batch 3** (10 tools) — find elements, JS eval + ref integration
|
||||
4. **Batch 4** (5 tools) — wait + batch, polling + composite
|
||||
5. **Batch 5** (11 tools) — cookies + storage, mixed WebKit API + JS
|
||||
6. **Batch 6** (13 tools) — mouse + clipboard + settings, mixed
|
||||
7. **Batch 7** (10 tools) — frames + dialogs + debug, WebKit API
|
||||
8. **Batch 8** (3 tools) — complex tools, WebKit API
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/agent_tools.c` — all tool implementations + dispatch table entries
|
||||
- `src/agent_mcp.c` — tool catalog entries (`tool_defs[]`) + schemas
|
||||
- `src/agent_tools.h` — no changes needed (dispatch API unchanged)
|
||||
- `src/tab_manager.c` / `src/tab_manager.h` — add `tab_manager_close_all()` for `close_all`
|
||||
- `src/agent_server.c` — may need console/error message signal handlers
|
||||
- `~/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json` — add new tools to `alwaysAllow`
|
||||
- `plans/agent-tools.md` — update Phase 3 status
|
||||
|
||||
## Key design decisions
|
||||
|
||||
### Find tools and ref integration
|
||||
The `find_*` tools need to assign refs so results work with `click @eN` etc. This means they must register the found element in `window.__agentRefs` (the same mechanism the snapshot uses). The find tool will:
|
||||
1. Run JS that finds the element and generates a unique CSS selector for it
|
||||
2. Assign a new ref (incrementing counter from `window.__agentRefCounter`)
|
||||
3. Store `{selector: "...", role: "...", name: "..."}` in `window.__agentRefs[eN]`
|
||||
4. Return the ref to the caller
|
||||
|
||||
### Batch tool
|
||||
The `batch` tool takes an array of tool requests and executes them sequentially:
|
||||
```json
|
||||
{"tool":"batch","params":{"commands":[
|
||||
{"tool":"open","params":{"url":"https://example.com"}},
|
||||
{"tool":"snapshot","params":{}},
|
||||
{"tool":"click","params":{"ref":"@e1"}}
|
||||
]}}
|
||||
```
|
||||
Returns an array of responses. Each command is dispatched via `agent_tools_dispatch()`. If a command fails, the batch stops (unless `continueOnError: true`).
|
||||
|
||||
### Console and error collection
|
||||
Need to connect to WebKitWebView's `console-message` signal and `notify::title` or `load-failed` signals. Store messages in a per-webview GArray or GPtrArray. The `console` and `errors` tools return the collected messages and optionally clear them.
|
||||
|
||||
### Dialog handling
|
||||
WebKitGTK emits `script-dialog` signal for alert/confirm/prompt. We need to:
|
||||
1. Connect to the signal
|
||||
2. Store the pending dialog (type, message, default text)
|
||||
3. The `dialog_accept`/`dialog_dismiss` tools call the appropriate `webkit_script_dialog_*_set_*()` function
|
||||
4. `dialog_status` returns whether a dialog is pending and its details
|
||||
|
||||
### Frame switching
|
||||
WebKitGTK doesn't have a direct "switch to frame" API like Selenium. We'll track a "current frame" selector in C state. All JS eval tools will wrap their scripts to execute within the frame context:
|
||||
```js
|
||||
(function() {
|
||||
var frame = document.querySelector('<frame_selector>');
|
||||
var doc = frame ? frame.contentDocument : document;
|
||||
// ... run tool JS within doc ...
|
||||
})();
|
||||
```
|
||||
`frame_main` resets the frame selector to NULL (use main document).
|
||||
|
||||
## Testing
|
||||
|
||||
After each batch:
|
||||
1. `make clean && make` — verify build
|
||||
2. Start browser, test each new tool with `curl` against `/mcp`
|
||||
3. Verify `tools/list` shows the new tools
|
||||
4. Update `mcp_settings.json` `alwaysAllow` array
|
||||
|
||||
## Mermaid: Implementation flow
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start[Phase 3 Start] --> B1[Batch 1: Extended Interaction<br/>11 tools, JS eval]
|
||||
B1 --> B2[Batch 2: Get Info + Check State<br/>7 tools, JS eval]
|
||||
B2 --> B3[Batch 3: Find Elements<br/>10 tools, JS eval + refs]
|
||||
B3 --> B4[Batch 4: Wait + Batch<br/>5 tools, polling + composite]
|
||||
B4 --> B5[Batch 5: Cookies + Storage<br/>11 tools, WebKit API + JS]
|
||||
B5 --> B6[Batch 6: Mouse + Clipboard + Settings<br/>13 tools, mixed]
|
||||
B6 --> B7[Batch 7: Frames + Dialogs + Debug<br/>10 tools, WebKit API]
|
||||
B7 --> B8[Batch 8: Complex<br/>3 tools, WebKit API]
|
||||
B8 --> Final[Final: Build, Test, Update Config + Docs]
|
||||
Final --> Done[100 tools total<br/>30 Phase 1 + 70 Phase 3]
|
||||
```
|
||||
@@ -0,0 +1,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 |
|
||||
+46
-3
@@ -144,6 +144,48 @@ static cJSON *login_local(cJSON *params) {
|
||||
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");
|
||||
@@ -380,7 +422,7 @@ cJSON *agent_login(cJSON *params) {
|
||||
|
||||
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)");
|
||||
return make_error("MISSING_METHOD", "Provide 'method' (local/seed/readonly/nip46/nsigner/generate)");
|
||||
}
|
||||
|
||||
/* Initialize nostr_core_lib if not already done. */
|
||||
@@ -395,6 +437,8 @@ cJSON *agent_login(cJSON *params) {
|
||||
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) {
|
||||
@@ -404,7 +448,7 @@ cJSON *agent_login(cJSON *params) {
|
||||
} else if (strcmp(method, "nsigner") == 0) {
|
||||
result = login_nsigner(params);
|
||||
} else {
|
||||
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, seed, readonly, nip46, or nsigner");
|
||||
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, generate, seed, readonly, nip46, or nsigner");
|
||||
}
|
||||
|
||||
/* If login succeeded, mark that the agent performed it. */
|
||||
@@ -417,7 +461,6 @@ cJSON *agent_login(cJSON *params) {
|
||||
cJSON *agent_logout(void) {
|
||||
app_clear_signer();
|
||||
nostr_bridge_set_signer(NULL, "", TRUE);
|
||||
key_store_clear();
|
||||
g_print("[agent-login] Logged out\n");
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
+545
-27
@@ -22,6 +22,79 @@
|
||||
#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) {
|
||||
@@ -61,16 +134,16 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"login",
|
||||
"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.",
|
||||
"{\"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, 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\"]}"},
|
||||
"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. Frees the old signer first.",
|
||||
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"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\"]}"},
|
||||
"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",
|
||||
@@ -114,15 +187,51 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
"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.",
|
||||
"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\"]}"},
|
||||
@@ -176,6 +285,269 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
{"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]);
|
||||
@@ -243,8 +615,43 @@ static void on_mcp_request(SoupServer *server,
|
||||
return;
|
||||
}
|
||||
|
||||
/* Only accept POST. */
|
||||
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;
|
||||
@@ -263,9 +670,10 @@ static void on_mcp_request(SoupServer *server,
|
||||
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, "application/json",
|
||||
SOUP_MEMORY_STATIC, err, strlen(err));
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_TAKE, sse, strlen(sse));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,11 +683,49 @@ static void on_mcp_request(SoupServer *server,
|
||||
cJSON *params = cJSON_GetObjectItem(request, "params");
|
||||
int rpc_id = (id_json && cJSON_IsNumber(id_json)) ? (int)id_json->valuedouble : 0;
|
||||
|
||||
/* ── Session validation ──────────────────────────────────────── *
|
||||
* For `initialize` we create a new session. For all other methods,
|
||||
* we check the Mcp-Session-Id header. If the header is present but
|
||||
* the session is unknown/expired, return 404. If the header is
|
||||
* absent, we proceed (lenient) for backward compatibility. */
|
||||
mcp_session_t *session = NULL;
|
||||
gboolean is_initialize = (rpc_method && strcmp(rpc_method, "initialize") == 0);
|
||||
|
||||
if (!is_initialize) {
|
||||
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
|
||||
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
|
||||
if (sid != NULL) {
|
||||
session = session_lookup(sid);
|
||||
if (session == NULL) {
|
||||
/* Unknown or expired session. */
|
||||
cJSON *err_resp = rpc_error(rpc_id, -32000, "Session not found or expired");
|
||||
char *err_str = cJSON_PrintUnformatted(err_resp);
|
||||
cJSON_Delete(err_resp);
|
||||
char *sse = build_sse_response(err_str);
|
||||
free(err_str);
|
||||
soup_server_message_set_status(msg, 404, NULL);
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_TAKE, sse, strlen(sse));
|
||||
cJSON_Delete(request);
|
||||
return;
|
||||
}
|
||||
session_touch(session);
|
||||
} else {
|
||||
g_warning("[mcp] Request without Mcp-Session-Id header (method=%s) — proceeding leniently",
|
||||
rpc_method ? rpc_method : "?");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON *response = NULL;
|
||||
|
||||
if (rpc_method == NULL) {
|
||||
response = rpc_error(rpc_id, -32600, "Invalid Request");
|
||||
} else if (strcmp(rpc_method, "initialize") == 0) {
|
||||
} 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");
|
||||
@@ -329,30 +775,87 @@ static void on_mcp_request(SoupServer *server,
|
||||
} else {
|
||||
/* Convert tool response to MCP format.
|
||||
* MCP tools/call returns: {content: [{type: "text", text: "..."}],
|
||||
* isError: false} */
|
||||
* 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();
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
free(resp_str);
|
||||
cJSON_Delete(tool_response);
|
||||
response = rpc_result(rpc_id, result);
|
||||
}
|
||||
}
|
||||
} else if (strcmp(rpc_method, "notifications/initialized") == 0) {
|
||||
/* Notification — no response needed, but we need to return 200. */
|
||||
} 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, 200, NULL);
|
||||
soup_server_message_set_response(msg, "application/json",
|
||||
SOUP_MEMORY_STATIC, "", 0);
|
||||
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");
|
||||
@@ -360,15 +863,25 @@ static void on_mcp_request(SoupServer *server,
|
||||
|
||||
cJSON_Delete(request);
|
||||
|
||||
/* Send the response. */
|
||||
/* 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);
|
||||
if (resp_str) {
|
||||
soup_server_message_set_status(msg, 200, NULL);
|
||||
soup_server_message_set_response(msg, "application/json",
|
||||
SOUP_MEMORY_TAKE, resp_str, strlen(resp_str));
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -377,6 +890,11 @@ static void on_mcp_request(SoupServer *server,
|
||||
/* ── 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());
|
||||
|
||||
@@ -337,6 +337,7 @@ const char *AGENT_SNAPSHOT_JS =
|
||||
" 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"
|
||||
@@ -344,7 +345,13 @@ const char *AGENT_SNAPSHOT_JS =
|
||||
" tag: el.tagName.toLowerCase(),\n"
|
||||
" href: el.href || '',\n"
|
||||
" type: el.type || '',\n"
|
||||
" value: el.value || ''\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"
|
||||
|
||||
+4745
-17
File diff suppressed because it is too large
Load Diff
+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,647 @@
|
||||
/*
|
||||
* db.c — SQLite storage for sovereign_browser
|
||||
*
|
||||
* Stores Nostr events and misc data in ~/.sovereign_browser/browser.db.
|
||||
* Uses SQLITE_OPEN_FULLMUTEX for thread safety (the relay fetch runs in
|
||||
* a background thread).
|
||||
*/
|
||||
|
||||
#include "db.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ── Global state ──────────────────────────────────────────────────── */
|
||||
|
||||
static sqlite3 *g_db = NULL;
|
||||
|
||||
/* Static buffer for db_kv_get() — valid until the next call. */
|
||||
static char g_kv_buf[4096];
|
||||
|
||||
/* ── Path helper ───────────────────────────────────────────────────── */
|
||||
|
||||
static int db_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/browser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Schema ────────────────────────────────────────────────────────── */
|
||||
|
||||
static const char *SCHEMA_SQL =
|
||||
"CREATE TABLE IF NOT EXISTS events ("
|
||||
" id TEXT PRIMARY KEY,"
|
||||
" pubkey TEXT NOT NULL,"
|
||||
" kind INTEGER NOT NULL,"
|
||||
" created_at INTEGER NOT NULL,"
|
||||
" content TEXT,"
|
||||
" sig TEXT,"
|
||||
" raw_json TEXT NOT NULL,"
|
||||
" fetched_at INTEGER NOT NULL"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS event_tags ("
|
||||
" event_id TEXT NOT NULL,"
|
||||
" tag_name TEXT NOT NULL,"
|
||||
" tag_value TEXT,"
|
||||
" position INTEGER NOT NULL,"
|
||||
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS key_value ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS history ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT NOT NULL UNIQUE,"
|
||||
" title TEXT,"
|
||||
" visited_at INTEGER NOT NULL,"
|
||||
" visit_count INTEGER DEFAULT 1"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visited_at "
|
||||
" ON history(visited_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_url "
|
||||
" ON history(url);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_title "
|
||||
" ON history(title);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visit_count "
|
||||
" ON history(visit_count DESC);"
|
||||
"CREATE TABLE IF NOT EXISTS session ("
|
||||
" tab_index INTEGER PRIMARY KEY,"
|
||||
" url TEXT NOT NULL,"
|
||||
" title TEXT"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
|
||||
" ON events(pubkey, kind, created_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
|
||||
" ON event_tags(tag_name, tag_value);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
|
||||
" ON event_tags(event_id);";
|
||||
|
||||
/* ── Init / Close ──────────────────────────────────────────────────── */
|
||||
|
||||
int db_init(void) {
|
||||
char path[512];
|
||||
if (db_path(path, sizeof(path)) != 0) {
|
||||
g_printerr("[db] Failed to get database path\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = sqlite3_open_v2(path, &g_db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
|
||||
SQLITE_OPEN_FULLMUTEX,
|
||||
NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to open database: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Set busy timeout to 5 seconds (in case of contention). */
|
||||
sqlite3_busy_timeout(g_db, 5000);
|
||||
|
||||
/* Enable foreign keys (for ON DELETE CASCADE on event_tags). */
|
||||
char *err = NULL;
|
||||
rc = sqlite3_exec(g_db, "PRAGMA foreign_keys = ON;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to enable foreign keys: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
}
|
||||
|
||||
/* Create schema. */
|
||||
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to create schema: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[db] Database initialized at %s\n", path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void db_close(void) {
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
g_print("[db] Database closed\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Events ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Helper: get a string field from a cJSON event, or "" if missing. */
|
||||
static const char *event_str(const cJSON *event, const char *field) {
|
||||
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
|
||||
if (cJSON_IsString(item)) return item->valuestring;
|
||||
return "";
|
||||
}
|
||||
|
||||
/* Helper: get an integer field from a cJSON event, or 0 if missing. */
|
||||
static long event_int(const cJSON *event, const char *field) {
|
||||
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
|
||||
if (cJSON_IsNumber(item)) return (long)item->valuedouble;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_store_event(const cJSON *event) {
|
||||
if (g_db == NULL || event == NULL) return -1;
|
||||
|
||||
const char *id = event_str(event, "id");
|
||||
const char *pubkey = event_str(event, "pubkey");
|
||||
long kind = event_int(event, "kind");
|
||||
long created = event_int(event, "created_at");
|
||||
const char *content = event_str(event, "content");
|
||||
const char *sig = event_str(event, "sig");
|
||||
|
||||
if (id[0] == '\0' || pubkey[0] == '\0') {
|
||||
g_printerr("[db] Event missing id or pubkey\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Serialize the full event to JSON for round-tripping. */
|
||||
char *raw_json = cJSON_PrintUnformatted(event);
|
||||
if (raw_json == NULL) return -1;
|
||||
|
||||
/* Begin transaction. */
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] BEGIN failed: %s\n", err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Upsert the event (INSERT OR REPLACE). */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT OR REPLACE INTO events "
|
||||
"(id, pubkey, kind, created_at, content, sig, raw_json, fetched_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'));",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare events insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, kind);
|
||||
sqlite3_bind_int64(stmt, 4, created);
|
||||
sqlite3_bind_text(stmt, 5, content, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, sig, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 7, raw_json, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] Events insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete old tags for this event (if replacing) and insert new ones. */
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"DELETE FROM event_tags WHERE event_id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
/* Insert tags. */
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (cJSON_IsArray(tags)) {
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO event_tags (event_id, tag_name, tag_value, position) "
|
||||
"VALUES (?, ?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
int pos = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *name_item = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *value_item = cJSON_GetArrayItem(tag, 1);
|
||||
const char *tag_name = (name_item && cJSON_IsString(name_item))
|
||||
? name_item->valuestring : "";
|
||||
const char *tag_value = (value_item && cJSON_IsString(value_item))
|
||||
? value_item->valuestring : "";
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, tag_name, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, tag_value, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, pos);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_reset(stmt);
|
||||
pos++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
/* Commit. */
|
||||
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] COMMIT failed: %s\n", err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(raw_json);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *db_get_latest_event(const char *pubkey_hex, int kind) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT raw_json FROM events "
|
||||
"WHERE pubkey = ? AND kind = ? "
|
||||
"ORDER BY created_at DESC LIMIT 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare latest_event failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
|
||||
cJSON *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (json_str) {
|
||||
result = cJSON_Parse(json_str);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
/* If limit is 0, use a large default. */
|
||||
int lim = (limit > 0) ? limit : 10000;
|
||||
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT raw_json FROM events "
|
||||
"WHERE pubkey = ? AND kind = ? "
|
||||
"ORDER BY created_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare get_events failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
sqlite3_bind_int(stmt, 3, lim);
|
||||
|
||||
cJSON *array = cJSON_CreateArray();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (json_str) {
|
||||
cJSON *event = cJSON_Parse(json_str);
|
||||
if (event) {
|
||||
cJSON_AddItemToArray(array, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return array;
|
||||
}
|
||||
|
||||
int db_count_events(const char *pubkey_hex, int kind) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return -1;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT COUNT(*) FROM events WHERE pubkey = ? AND kind = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
|
||||
int count = -1;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
count = sqlite3_column_int(stmt, 0);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
int db_kv_set(const char *key, const char *value) {
|
||||
if (g_db == NULL || key == NULL || value == NULL) return -1;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
const char *db_kv_get(const char *key) {
|
||||
if (g_db == NULL || key == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT value FROM key_value WHERE key = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return NULL;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
|
||||
const char *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *val = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (val) {
|
||||
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
|
||||
result = g_kv_buf;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *db_kv_get_copy(const char *key) {
|
||||
const char *val = db_kv_get(key);
|
||||
if (val == NULL) return NULL;
|
||||
return g_strdup(val);
|
||||
}
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_add(const char *url, const char *title) {
|
||||
if (g_db == NULL || url == NULL || url[0] == '\0') return -1;
|
||||
|
||||
/* UPSERT: insert or update visited_at + visit_count. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO history (url, title, visited_at, visit_count) "
|
||||
"VALUES (?, ?, strftime('%s','now'), 1) "
|
||||
"ON CONFLICT(url) DO UPDATE SET "
|
||||
" title = excluded.title, "
|
||||
" visited_at = strftime('%s','now'), "
|
||||
" visit_count = visit_count + 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
if (limit <= 0) limit = 50;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"ORDER BY visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_int(stmt, 1, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_history_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── History search ────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || query == NULL || urls_out == NULL || count_out == NULL)
|
||||
return -1;
|
||||
if (limit <= 0) limit = 10;
|
||||
|
||||
/* Build a LIKE pattern: %query% (case-insensitive via COLLATE NOCASE
|
||||
* is not needed — SQLite LIKE is case-insensitive for ASCII by default). */
|
||||
char *pattern = g_strdup_printf("%%%s%%", query);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"WHERE url LIKE ? OR title LIKE ? "
|
||||
"ORDER BY visit_count DESC, visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_free(pattern);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pattern, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern, -1, NULL); /* same pattern, freed once */
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Re-bind (sqlite3_reset keeps bindings, but reset clears them in some
|
||||
* versions — re-bind to be safe). */
|
||||
char *pattern2 = g_strdup_printf("%%%s%%", query);
|
||||
sqlite3_bind_text(stmt, 1, pattern2, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern2, -1, NULL);
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_session_save(const char **urls, const char **titles, int count) {
|
||||
if (g_db == NULL || urls == NULL) return -1;
|
||||
|
||||
/* Clear the session table first. */
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Insert each tab. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (urls[i] == NULL) continue;
|
||||
sqlite3_bind_int(stmt, 1, i);
|
||||
sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3,
|
||||
(titles && titles[i]) ? titles[i] : "",
|
||||
-1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_reset(stmt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
|
||||
/* Count rows. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM session ORDER BY tab_index;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
if (count == 0) {
|
||||
sqlite3_finalize(stmt);
|
||||
*count_out = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
*count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_session_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* db.h — SQLite storage for sovereign_browser
|
||||
*
|
||||
* Provides persistent local storage for Nostr events and misc data.
|
||||
* The database lives at ~/.sovereign_browser/browser.db.
|
||||
*
|
||||
* Tables:
|
||||
* events — Nostr events (id, pubkey, kind, created_at, content, sig, raw_json)
|
||||
* event_tags — Tag rows for querying by tag name/value
|
||||
* key_value — Simple key-value store for misc settings/cache
|
||||
*
|
||||
* Thread safety: the database is opened with SQLITE_OPEN_FULLMUTEX, so
|
||||
* calls from multiple threads are safe (serialized by SQLite's mutex).
|
||||
*/
|
||||
|
||||
#ifndef DB_H
|
||||
#define DB_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* cJSON is in the vendored nostr_core_lib */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Initialize the database at ~/.sovereign_browser/browser.db.
|
||||
* Creates tables and indexes if they don't exist.
|
||||
* Call once at startup (after settings_load()).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init(void);
|
||||
|
||||
/*
|
||||
* Close the database. Call at shutdown.
|
||||
*/
|
||||
void db_close(void);
|
||||
|
||||
/* ── Events ────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Store a Nostr event (upsert — replaces if same event_id exists).
|
||||
* Parses the cJSON event and stores it in the events + event_tags tables.
|
||||
*
|
||||
* event — a cJSON object representing a Nostr event with at least:
|
||||
* id, pubkey, kind, created_at, content, sig, tags
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_store_event(const cJSON *event);
|
||||
|
||||
/*
|
||||
* Fetch the most recent event of a given kind for a pubkey.
|
||||
*
|
||||
* Returns a newly allocated cJSON event (parsed from raw_json), or NULL
|
||||
* if not found. Caller must cJSON_Delete() the result.
|
||||
*/
|
||||
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
|
||||
|
||||
/*
|
||||
* Fetch all events of a given kind for a pubkey, newest first.
|
||||
*
|
||||
* limit — max number of events (0 = no limit)
|
||||
*
|
||||
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
|
||||
* Returns NULL on error.
|
||||
*/
|
||||
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
|
||||
|
||||
/*
|
||||
* Count events of a given kind for a pubkey.
|
||||
* Returns the count, or -1 on error.
|
||||
*/
|
||||
int db_count_events(const char *pubkey_hex, int kind);
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Set a key-value pair (upsert).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set(const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Get a value by key.
|
||||
* Returns a pointer to the value string, or NULL if not found.
|
||||
* The pointer is valid until the next db_kv_get() call (uses a static
|
||||
* buffer). Call db_kv_get_copy() if you need a persistent copy.
|
||||
*/
|
||||
const char *db_kv_get(const char *key);
|
||||
|
||||
/*
|
||||
* Get a value by key, returning a newly allocated copy.
|
||||
* Caller must g_free() the result. Returns NULL if not found.
|
||||
*/
|
||||
char *db_kv_get_copy(const char *key);
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Add a URL to the history (UPSERT — increments visit_count and updates
|
||||
* visited_at if the URL already exists).
|
||||
* url — the URL to record
|
||||
* title — optional page title (NULL or "" for none)
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_add(const char *url, const char *title);
|
||||
|
||||
/*
|
||||
* Get recent history entries (most-recent-first).
|
||||
* limit — max number of entries (0 = default 50)
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/*
|
||||
* Clear all history entries.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_clear(void);
|
||||
|
||||
/*
|
||||
* Search history entries by URL or title substring.
|
||||
* query — substring to search for (case-insensitive)
|
||||
* limit — max number of entries (0 = default 10)
|
||||
*
|
||||
* Results are ranked by visit_count DESC, then visited_at DESC — so
|
||||
* frequently-visited and recently-visited sites appear first.
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Save the current session (open tab URLs) to the database.
|
||||
* Clears the session table first, then inserts the given URLs in order.
|
||||
* urls — array of URL strings
|
||||
* titles — array of title strings (can be NULL for no titles)
|
||||
* count — number of tabs
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_save(const char **urls, const char **titles, int count);
|
||||
|
||||
/*
|
||||
* Load the saved session from the database.
|
||||
* Fills urls_out and titles_out with parallel arrays (tab_index order).
|
||||
* Caller must free each string and the arrays.
|
||||
* Returns the number of tabs, or -1 on error / no session.
|
||||
*/
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
|
||||
/*
|
||||
* Clear the saved session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_clear(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DB_H */
|
||||
+87
-136
@@ -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);
|
||||
|
||||
|
||||
+34
-192
@@ -1,5 +1,14 @@
|
||||
/*
|
||||
* key_store.c — Nostr identity persistence for sovereign_browser
|
||||
* key_store.c — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Private keys are NEVER persisted to disk. They live only in RAM for
|
||||
* the duration of the session and are gone when the browser quits.
|
||||
*
|
||||
* This module provides:
|
||||
* - key_store_create_signer() — create a nostr_signer_t from an
|
||||
* in-memory identity struct
|
||||
* - key_store_delete_legacy_file() — defensively delete any
|
||||
* identity.json left by a previous version that persisted keys
|
||||
*/
|
||||
|
||||
#include "key_store.h"
|
||||
@@ -7,203 +16,14 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "nostr_core/nip006.h"
|
||||
#include "nostr_core/nip019.h"
|
||||
|
||||
/* ── Path helpers ─────────────────────────────────────────────── */
|
||||
|
||||
int key_store_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Ensure ~/.sovereign_browser/ exists. */
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/identity.json", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Save / load ──────────────────────────────────────────────── */
|
||||
|
||||
static const char *method_to_string(key_store_method_t m) {
|
||||
switch (m) {
|
||||
case KEY_STORE_METHOD_LOCAL: return "local";
|
||||
case KEY_STORE_METHOD_SEED: return "seed";
|
||||
case KEY_STORE_METHOD_READONLY: return "readonly";
|
||||
case KEY_STORE_METHOD_NIP46: return "nip46";
|
||||
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
static key_store_method_t string_to_method(const char *s) {
|
||||
if (!s) return KEY_STORE_METHOD_NONE;
|
||||
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
|
||||
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
|
||||
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
|
||||
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
|
||||
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
|
||||
return KEY_STORE_METHOD_NONE;
|
||||
}
|
||||
|
||||
int key_store_save(const key_store_identity_t *identity) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Write with restrictive permissions. */
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
|
||||
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_LOCAL ||
|
||||
identity->method == KEY_STORE_METHOD_SEED) {
|
||||
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
|
||||
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
|
||||
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
|
||||
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
|
||||
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
|
||||
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
|
||||
}
|
||||
|
||||
fprintf(f, "\n}\n");
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
|
||||
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
/* Skip whitespace and colon. */
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
if (*p != '"') return -1;
|
||||
p++;
|
||||
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i < out_sz - 1) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
}
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
return (i > 0 || *p == '"') ? 0 : -1;
|
||||
}
|
||||
|
||||
static int json_extract_int(const char *json, const char *key, int *out) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
char *end;
|
||||
long val = strtol(p, &end, 10);
|
||||
if (end == p) return -1;
|
||||
*out = (int)val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_load(key_store_identity_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
if (errno == ENOENT) return 1; /* no identity file */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Read the whole file (small). */
|
||||
char buf[4096];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
char method_str[32];
|
||||
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
out->method = string_to_method(method_str);
|
||||
if (out->method == KEY_STORE_METHOD_NONE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
|
||||
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
|
||||
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
|
||||
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
|
||||
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
|
||||
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_clear(void) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Signer creation ──────────────────────────────────────────── */
|
||||
/* ── Signer creation ──────────────────────────────────────────────── */
|
||||
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
if (identity == NULL) {
|
||||
@@ -267,3 +87,25 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Legacy file cleanup ──────────────────────────────────────────── */
|
||||
|
||||
int key_store_delete_legacy_file(void) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json",
|
||||
home);
|
||||
if (n < 0 || (size_t)n >= sizeof(path)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete the file if it exists. ENOENT is not an error. */
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
+20
-33
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* key_store.h — Nostr identity persistence for sovereign_browser
|
||||
* key_store.h — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
|
||||
* so the browser can restore the signer on startup without re-prompting.
|
||||
* IMPORTANT: Private keys are NEVER persisted to disk. They live only in
|
||||
* working memory (RAM) for the duration of the session and are gone when
|
||||
* the browser quits. The user must sign in again on each launch.
|
||||
*
|
||||
* The key_store_identity_t struct is populated by the login dialog or CLI
|
||||
* login, held in memory, and used to create a nostr_signer_t. It is not
|
||||
* written to any file.
|
||||
*/
|
||||
|
||||
#ifndef KEY_STORE_H
|
||||
@@ -18,14 +23,14 @@ extern "C" {
|
||||
/* Login methods supported by the browser. */
|
||||
typedef enum {
|
||||
KEY_STORE_METHOD_NONE = 0,
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */
|
||||
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
|
||||
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
|
||||
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
|
||||
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
|
||||
} key_store_method_t;
|
||||
|
||||
/* Identity record persisted to disk. */
|
||||
/* Identity record (in-memory only — never persisted). */
|
||||
typedef struct {
|
||||
key_store_method_t method;
|
||||
|
||||
@@ -33,10 +38,10 @@ typedef struct {
|
||||
char pubkey_hex[65];
|
||||
|
||||
/* Hex private key (local + seed methods only; 64 chars + NUL).
|
||||
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
|
||||
* Held in memory only — never written to disk. */
|
||||
char privkey_hex[65];
|
||||
|
||||
/* BIP-39 mnemonic (seed method only). */
|
||||
/* BIP-39 mnemonic (seed method only). In memory only. */
|
||||
char mnemonic[256];
|
||||
|
||||
/* NIP-46 bunker URL (nip46 method only). */
|
||||
@@ -49,32 +54,7 @@ typedef struct {
|
||||
} key_store_identity_t;
|
||||
|
||||
/*
|
||||
* Get the path to the identity file (~/.sovereign_browser/identity.json).
|
||||
* Creates the directory if it doesn't exist.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_path(char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Save an identity to disk.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_save(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Load an identity from disk.
|
||||
* Returns 0 on success, 1 if no identity file exists, -1 on error.
|
||||
*/
|
||||
int key_store_load(key_store_identity_t *out);
|
||||
|
||||
/*
|
||||
* Delete the identity file (logout).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_clear(void);
|
||||
|
||||
/*
|
||||
* Create a nostr_signer_t from a loaded identity.
|
||||
* Create a nostr_signer_t from an in-memory identity.
|
||||
* For readonly: returns NULL (caller should use pubkey_hex directly).
|
||||
* For nsigner: creates the appropriate transport-backed signer.
|
||||
* Caller must free the returned signer with nostr_signer_free().
|
||||
@@ -82,6 +62,13 @@ int key_store_clear(void);
|
||||
*/
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Defensively delete any legacy identity.json file from a previous
|
||||
* version that persisted keys to disk. Call once at startup.
|
||||
* Returns 0 on success (including if the file doesn't exist), -1 on error.
|
||||
*/
|
||||
int key_store_delete_legacy_file(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+26
-3
@@ -79,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. */
|
||||
@@ -578,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;
|
||||
@@ -1084,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();
|
||||
@@ -1153,10 +1174,12 @@ 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);
|
||||
|
||||
|
||||
+300
-209
@@ -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,10 +39,16 @@
|
||||
#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 "relay_fetch.h"
|
||||
#include "bookmarks.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -60,6 +67,7 @@ typedef struct {
|
||||
static app_state_t g_state = {0};
|
||||
static GtkWindow *g_window = NULL;
|
||||
static gboolean g_logged_in = FALSE;
|
||||
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
|
||||
|
||||
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
|
||||
|
||||
@@ -73,6 +81,10 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
}
|
||||
g_logged_in = TRUE;
|
||||
|
||||
/* Update modules that hold a signer reference. */
|
||||
settings_sync_set_signer(signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
}
|
||||
|
||||
void app_clear_signer(void) {
|
||||
@@ -84,6 +96,8 @@ void app_clear_signer(void) {
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
}
|
||||
|
||||
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
|
||||
@@ -143,7 +157,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");
|
||||
}
|
||||
|
||||
@@ -189,192 +202,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);
|
||||
@@ -383,35 +269,82 @@ 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 ------------------------------------------ *
|
||||
@@ -443,6 +376,8 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
nostr_cleanup();
|
||||
bookmarks_cleanup();
|
||||
db_close();
|
||||
gtk_main_quit();
|
||||
}
|
||||
|
||||
@@ -480,11 +415,16 @@ static int do_login(GtkWindow *parent) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -493,13 +433,85 @@ 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();
|
||||
/* Defensively delete any legacy identity.json from a previous version
|
||||
* that persisted private keys to disk. Keys are now in-memory only. */
|
||||
key_store_delete_legacy_file();
|
||||
|
||||
const char *start_url = (argc > 1) ? argv[1] : NULL;
|
||||
/* Initialize the SQLite database first — settings and history are
|
||||
* stored there. */
|
||||
db_init();
|
||||
|
||||
/* Load settings from the database (key_value table). */
|
||||
settings_load();
|
||||
|
||||
/* Load keyboard shortcut bindings from the database. Must come after
|
||||
* settings_load() because shortcuts_lookup() checks the master
|
||||
* ctrl_tab_switch toggle. */
|
||||
shortcuts_load();
|
||||
|
||||
/* History is queried from the SQLite database on demand — no
|
||||
* separate load step needed. */
|
||||
|
||||
/* 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);
|
||||
@@ -529,6 +541,7 @@ int main(int argc, char **argv) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -536,6 +549,29 @@ int main(int argc, char **argv) {
|
||||
* 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
|
||||
@@ -549,10 +585,39 @@ int main(int argc, char **argv) {
|
||||
g_print("[login] Cancelled, exiting.\n");
|
||||
agent_server_stop();
|
||||
nostr_cleanup();
|
||||
cli_args_free(&cli);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
/* If the user logged in with a Nostr identity (not --no-login), start
|
||||
* a background thread to fetch their kind 0/3/10002 events from the
|
||||
* bootstrap relays. The results are cached in the SQLite database.
|
||||
* The thread frees the pubkey copy when done. */
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
char *pubkey_copy = g_strdup(g_state.pubkey_hex);
|
||||
g_thread_new("relay-fetch", relay_fetch_thread, pubkey_copy);
|
||||
g_print("[relay] Bootstrap fetch thread started for %s\n",
|
||||
g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -561,10 +626,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");
|
||||
|
||||
@@ -574,6 +641,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);
|
||||
@@ -585,15 +660,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
|
||||
@@ -608,6 +697,8 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
gtk_main();
|
||||
|
||||
|
||||
+1359
-84
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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
+119
-121
@@ -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,6 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* ── Global singleton ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -37,30 +36,15 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
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 ──────────────────────────────────────────────── */
|
||||
@@ -100,115 +84,129 @@ 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) ─────────────────────────── */
|
||||
|
||||
void settings_load(void) {
|
||||
settings_set_defaults(&g_settings);
|
||||
|
||||
char path[512];
|
||||
if (settings_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
/* Read each setting from the key_value table. If a key is missing
|
||||
* (NULL), the default is kept. */
|
||||
const char *val;
|
||||
|
||||
val = db_kv_get("restore_session");
|
||||
if (val) g_settings.restore_session = parse_bool(val, g_settings.restore_session);
|
||||
|
||||
val = db_kv_get("new_tab_url");
|
||||
if (val) snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), "%s", val);
|
||||
|
||||
val = db_kv_get("tab_bar_position");
|
||||
if (val) g_settings.tab_bar_position = parse_position(val, g_settings.tab_bar_position);
|
||||
|
||||
val = db_kv_get("show_tab_close_buttons");
|
||||
if (val) g_settings.show_tab_close_buttons = parse_bool(val, g_settings.show_tab_close_buttons);
|
||||
|
||||
val = db_kv_get("middle_click_close");
|
||||
if (val) g_settings.middle_click_close = parse_bool(val, g_settings.middle_click_close);
|
||||
|
||||
val = db_kv_get("ctrl_tab_switch");
|
||||
if (val) g_settings.ctrl_tab_switch = parse_bool(val, g_settings.ctrl_tab_switch);
|
||||
|
||||
val = db_kv_get("max_tabs");
|
||||
if (val) {
|
||||
g_settings.max_tabs = parse_int(val, g_settings.max_tabs);
|
||||
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
val = db_kv_get("tab_drag_reorder");
|
||||
if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder);
|
||||
|
||||
val = db_kv_get("agent_server_enabled");
|
||||
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
|
||||
|
||||
val = db_kv_get("agent_server_port");
|
||||
if (val) {
|
||||
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
|
||||
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
}
|
||||
|
||||
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;
|
||||
val = db_kv_get("agent_allowed_origins");
|
||||
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
|
||||
|
||||
/* Split key=value. */
|
||||
char *eq = strchr(line, '=');
|
||||
if (eq == NULL) continue;
|
||||
*eq = '\0';
|
||||
char *key = line;
|
||||
char *value = eq + 1;
|
||||
|
||||
/* Trim whitespace from key. */
|
||||
while (*key == ' ' || *key == '\t') key++;
|
||||
char *kend = key + strlen(key);
|
||||
while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0';
|
||||
|
||||
/* Trim whitespace from value. */
|
||||
while (*value == ' ' || *value == '\t') value++;
|
||||
|
||||
if (strcmp(key, "restore_session") == 0) {
|
||||
g_settings.restore_session = parse_bool(value, g_settings.restore_session);
|
||||
} else if (strcmp(key, "new_tab_url") == 0) {
|
||||
snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url),
|
||||
"%s", value);
|
||||
} else if (strcmp(key, "tab_bar_position") == 0) {
|
||||
g_settings.tab_bar_position = parse_position(value, g_settings.tab_bar_position);
|
||||
} else if (strcmp(key, "show_tab_close_buttons") == 0) {
|
||||
g_settings.show_tab_close_buttons = parse_bool(value, g_settings.show_tab_close_buttons);
|
||||
} else if (strcmp(key, "middle_click_close") == 0) {
|
||||
g_settings.middle_click_close = parse_bool(value, g_settings.middle_click_close);
|
||||
} else if (strcmp(key, "ctrl_tab_switch") == 0) {
|
||||
g_settings.ctrl_tab_switch = parse_bool(value, g_settings.ctrl_tab_switch);
|
||||
} else if (strcmp(key, "max_tabs") == 0) {
|
||||
g_settings.max_tabs = parse_int(value, g_settings.max_tabs);
|
||||
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
|
||||
} else if (strcmp(key, "tab_drag_reorder") == 0) {
|
||||
g_settings.tab_drag_reorder = parse_bool(value, g_settings.tab_drag_reorder);
|
||||
} else if (strcmp(key, "agent_server_enabled") == 0) {
|
||||
g_settings.agent_server_enabled = parse_bool(value, g_settings.agent_server_enabled);
|
||||
} else if (strcmp(key, "agent_server_port") == 0) {
|
||||
g_settings.agent_server_port = parse_int(value, g_settings.agent_server_port);
|
||||
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
} else if (strcmp(key, "agent_allowed_origins") == 0) {
|
||||
snprintf(g_settings.agent_allowed_origins,
|
||||
sizeof(g_settings.agent_allowed_origins), "%s", value);
|
||||
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
|
||||
g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms);
|
||||
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
|
||||
}
|
||||
/* Unknown keys are silently ignored. */
|
||||
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;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
val = db_kv_get("bootstrap_relays");
|
||||
if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val);
|
||||
|
||||
val = db_kv_get("search_engine");
|
||||
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
|
||||
|
||||
val = db_kv_get("theme_dark");
|
||||
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
|
||||
|
||||
val = db_kv_get("inspector_x");
|
||||
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
|
||||
val = db_kv_get("inspector_y");
|
||||
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
|
||||
val = db_kv_get("inspector_w");
|
||||
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
|
||||
val = db_kv_get("inspector_h");
|
||||
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
|
||||
}
|
||||
|
||||
void settings_save(void) {
|
||||
char path[512];
|
||||
if (settings_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
char buf[32];
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
fchmod(fileno(f), 0600);
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.max_tabs);
|
||||
db_kv_set("max_tabs", buf);
|
||||
|
||||
fprintf(f, "# sovereign_browser settings\n");
|
||||
fprintf(f, "restore_session=%s\n", g_settings.restore_session ? "true" : "false");
|
||||
fprintf(f, "new_tab_url=%s\n", g_settings.new_tab_url);
|
||||
fprintf(f, "tab_bar_position=%s\n", pos_str);
|
||||
fprintf(f, "show_tab_close_buttons=%s\n", g_settings.show_tab_close_buttons ? "true" : "false");
|
||||
fprintf(f, "middle_click_close=%s\n", g_settings.middle_click_close ? "true" : "false");
|
||||
fprintf(f, "ctrl_tab_switch=%s\n", g_settings.ctrl_tab_switch ? "true" : "false");
|
||||
fprintf(f, "max_tabs=%d\n", g_settings.max_tabs);
|
||||
fprintf(f, "tab_drag_reorder=%s\n", g_settings.tab_drag_reorder ? "true" : "false");
|
||||
fprintf(f, "agent_server_enabled=%s\n", g_settings.agent_server_enabled ? "true" : "false");
|
||||
fprintf(f, "agent_server_port=%d\n", g_settings.agent_server_port);
|
||||
fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins);
|
||||
fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms);
|
||||
db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false");
|
||||
db_kv_set("agent_server_enabled", g_settings.agent_server_enabled ? "true" : "false");
|
||||
|
||||
fclose(f);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
|
||||
db_kv_set("agent_server_port", buf);
|
||||
|
||||
db_kv_set("agent_allowed_origins", g_settings.agent_allowed_origins);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
|
||||
db_kv_set("agent_login_timeout_ms", buf);
|
||||
|
||||
/* Bootstrap relays are stored as-is (newlines are fine in SQLite). */
|
||||
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
|
||||
|
||||
db_kv_set("search_engine", g_settings.search_engine);
|
||||
db_kv_set("theme_dark", g_settings.theme_dark ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
|
||||
db_kv_set("inspector_x", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
|
||||
db_kv_set("inspector_y", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
|
||||
db_kv_set("inspector_w", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
|
||||
db_kv_set("inspector_h", buf);
|
||||
}
|
||||
|
||||
const browser_settings_t *settings_get(void) {
|
||||
|
||||
@@ -21,6 +21,13 @@ extern "C" {
|
||||
#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 */
|
||||
@@ -35,6 +42,13 @@ typedef struct {
|
||||
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;
|
||||
|
||||
/*
|
||||
|
||||
@@ -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 */
|
||||
+1458
-25
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.11"
|
||||
#define SB_VERSION "v0.0.16"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 11
|
||||
#define SB_VERSION_PATCH 16
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
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