v0.0.13 - Consolidated settings into sovereign://settings page, added QR codes (libqrencode), SQLite event storage, No-Login mode, bootstrap relay fetch, sovereign://profile page, and fixed browser.sh stop killing Roo Code
This commit is contained in:
@@ -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
|
||||
|
||||
$(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
|
||||
@@ -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,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
-2
@@ -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. */
|
||||
|
||||
+4
-4
@@ -134,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",
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* cli.c — command-line flag parsing for sovereign_browser
|
||||
*
|
||||
* Uses getopt_long to parse flags before gtk_init(). Recognized flags
|
||||
* are stripped from argv (getopt_long reorders argv in place; optind
|
||||
* points at the first positional arg on return). We collect positional
|
||||
* args as startup URLs.
|
||||
*
|
||||
* Login flags build a cJSON params object matching the shape documented
|
||||
* in agent_login.h and call agent_login(), reusing the same code path
|
||||
* as the MCP 'login' tool.
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L /* strdup, getopt_long */
|
||||
|
||||
#include "cli.h"
|
||||
#include "version.h"
|
||||
#include "agent_login.h"
|
||||
#include "key_store.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <getopt.h>
|
||||
|
||||
/* ── Usage text ────────────────────────────────────────────────────── */
|
||||
|
||||
void cli_print_usage(FILE *fp) {
|
||||
fprintf(fp,
|
||||
"sovereign_browser %s — WebKitGTK + C99\n"
|
||||
"\n"
|
||||
"Usage: sovereign_browser [OPTIONS] [URL...]\n"
|
||||
"\n"
|
||||
"Positional URLs are opened in tabs at startup (unless a session is\n"
|
||||
"restored). --url flags are equivalent and may be repeated.\n"
|
||||
"\n"
|
||||
"Browser / Startup:\n"
|
||||
" --url <url> Open URL in a tab (repeatable)\n"
|
||||
" --new-tab-url <url> Override the default new-tab URL\n"
|
||||
" --no-session-restore Skip session restore for this run\n"
|
||||
" --session-restore Force session restore even if disabled\n"
|
||||
" --max-tabs <n> Override max simultaneous tabs\n"
|
||||
" -V, --version Print version and exit\n"
|
||||
" -h, --help Show this help and exit\n"
|
||||
"\n"
|
||||
"Agent Server:\n"
|
||||
" --port <port> Override agent MCP server port\n"
|
||||
" --no-agent Disable the agent server for this run\n"
|
||||
" --agent Force-enable the agent server\n"
|
||||
" --agent-origin <origin> Allowed origin (repeatable)\n"
|
||||
"\n"
|
||||
"Login (skips the GTK login dialog):\n"
|
||||
" --no-login Browse without a Nostr identity (normal browser mode)\n"
|
||||
" --login-method <m> Method: generate|local|seed|readonly|nip46|nsigner\n"
|
||||
" --nsec <nsec1...> (local) nsec bech32 private key\n"
|
||||
" --privkey <hex> (local) 64-char hex private key\n"
|
||||
" --mnemonic <words> (seed) BIP-39 mnemonic, quoted\n"
|
||||
" --account <n> (seed) BIP-44 account index (default 0)\n"
|
||||
" --npub <npub1...> (readonly) npub bech32 public key\n"
|
||||
" --pubkey <hex> (readonly) 64-char hex pubkey\n"
|
||||
" --bunker <url> (nip46) bunker:// URL\n"
|
||||
" --nsigner-transport <t> (nsigner) serial|unix|tcp|qrexec\n"
|
||||
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
|
||||
" --nsigner-service <name> (nsigner) qrexec service name\n"
|
||||
" --nsigner-index <n> (nsigner) key index (default 0)\n"
|
||||
" --no-save-identity Do not persist identity to disk\n"
|
||||
" --login-timeout <ms> Override agent login timeout (GTK dialog path)\n"
|
||||
"\n"
|
||||
"Diagnostics:\n"
|
||||
" -v, --verbose Increase log verbosity (repeatable)\n"
|
||||
" -q, --quiet Suppress non-error log output\n"
|
||||
"\n"
|
||||
"Examples:\n"
|
||||
" sovereign_browser https://example.com\n"
|
||||
" sovereign_browser --login-method generate --url https://example.com\n"
|
||||
" sovereign_browser --login-method local --nsec nsec1... --port 18888\n"
|
||||
" sovereign_browser --login-method readonly --npub npub1... --no-agent\n"
|
||||
" sovereign_browser --no-agent --url https://a.com --url https://b.com\n",
|
||||
SB_VERSION);
|
||||
}
|
||||
|
||||
/* ── getopt_long option table ──────────────────────────────────────── */
|
||||
|
||||
enum {
|
||||
OPT_URL = 1000,
|
||||
OPT_NEW_TAB_URL,
|
||||
OPT_NO_SESSION_RESTORE,
|
||||
OPT_SESSION_RESTORE,
|
||||
OPT_MAX_TABS,
|
||||
OPT_VERSION,
|
||||
OPT_PORT,
|
||||
OPT_NO_AGENT,
|
||||
OPT_AGENT,
|
||||
OPT_AGENT_ORIGIN,
|
||||
OPT_LOGIN_METHOD,
|
||||
OPT_NSEC,
|
||||
OPT_PRIVKEY,
|
||||
OPT_MNEMONIC,
|
||||
OPT_ACCOUNT,
|
||||
OPT_NPUB,
|
||||
OPT_PUBKEY,
|
||||
OPT_BUNKER,
|
||||
OPT_NSIGNER_TRANSPORT,
|
||||
OPT_NSIGNER_DEVICE,
|
||||
OPT_NSIGNER_SERVICE,
|
||||
OPT_NSIGNER_INDEX,
|
||||
OPT_NO_SAVE_IDENTITY,
|
||||
OPT_NO_LOGIN,
|
||||
OPT_LOGIN_TIMEOUT,
|
||||
OPT_VERBOSE,
|
||||
OPT_QUIET,
|
||||
OPT_HELP,
|
||||
};
|
||||
|
||||
static struct option long_opts[] = {
|
||||
/* Browser / startup */
|
||||
{"url", required_argument, 0, OPT_URL},
|
||||
{"new-tab-url", required_argument, 0, OPT_NEW_TAB_URL},
|
||||
{"no-session-restore", no_argument, 0, OPT_NO_SESSION_RESTORE},
|
||||
{"session-restore", no_argument, 0, OPT_SESSION_RESTORE},
|
||||
{"max-tabs", required_argument, 0, OPT_MAX_TABS},
|
||||
{"version", no_argument, 0, OPT_VERSION},
|
||||
/* Agent server */
|
||||
{"port", required_argument, 0, OPT_PORT},
|
||||
{"no-agent", no_argument, 0, OPT_NO_AGENT},
|
||||
{"agent", no_argument, 0, OPT_AGENT},
|
||||
{"agent-origin", required_argument, 0, OPT_AGENT_ORIGIN},
|
||||
/* Login */
|
||||
{"login-method", required_argument, 0, OPT_LOGIN_METHOD},
|
||||
{"nsec", required_argument, 0, OPT_NSEC},
|
||||
{"privkey", required_argument, 0, OPT_PRIVKEY},
|
||||
{"mnemonic", required_argument, 0, OPT_MNEMONIC},
|
||||
{"account", required_argument, 0, OPT_ACCOUNT},
|
||||
{"npub", required_argument, 0, OPT_NPUB},
|
||||
{"pubkey", required_argument, 0, OPT_PUBKEY},
|
||||
{"bunker", required_argument, 0, OPT_BUNKER},
|
||||
{"nsigner-transport", required_argument, 0, OPT_NSIGNER_TRANSPORT},
|
||||
{"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE},
|
||||
{"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE},
|
||||
{"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX},
|
||||
{"no-save-identity", no_argument, 0, OPT_NO_SAVE_IDENTITY},
|
||||
{"no-login", no_argument, 0, OPT_NO_LOGIN},
|
||||
{"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT},
|
||||
/* Diagnostics */
|
||||
{"verbose", no_argument, 0, OPT_VERBOSE},
|
||||
{"quiet", no_argument, 0, OPT_QUIET},
|
||||
{"help", no_argument, 0, OPT_HELP},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
static const char *short_opts = "vVhq";
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
static char *xstrdup(const char *s) {
|
||||
if (s == NULL) return NULL;
|
||||
char *p = strdup(s);
|
||||
if (p == NULL) {
|
||||
fprintf(stderr, "[cli] out of memory\n");
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static void add_string(char ***arr, int *count, const char *value) {
|
||||
*arr = g_realloc(*arr, sizeof(char *) * (*count + 1));
|
||||
(*arr)[*count] = xstrdup(value);
|
||||
(*count)++;
|
||||
}
|
||||
|
||||
static int valid_login_method(const char *m) {
|
||||
return strcmp(m, "generate") == 0 ||
|
||||
strcmp(m, "local") == 0 ||
|
||||
strcmp(m, "seed") == 0 ||
|
||||
strcmp(m, "readonly") == 0 ||
|
||||
strcmp(m, "nip46") == 0 ||
|
||||
strcmp(m, "nsigner") == 0;
|
||||
}
|
||||
|
||||
/* ── cli_args_free ─────────────────────────────────────────────────── */
|
||||
|
||||
void cli_args_free(cli_args_t *args) {
|
||||
if (args == NULL) return;
|
||||
if (args->urls) {
|
||||
for (int i = 0; i < args->url_count; i++) g_free(args->urls[i]);
|
||||
g_free(args->urls);
|
||||
}
|
||||
g_free(args->new_tab_url);
|
||||
if (args->agent_origins) {
|
||||
for (int i = 0; i < args->agent_origin_count; i++) g_free(args->agent_origins[i]);
|
||||
g_free(args->agent_origins);
|
||||
}
|
||||
g_free(args->login_method);
|
||||
g_free(args->nsec);
|
||||
g_free(args->privkey);
|
||||
g_free(args->mnemonic);
|
||||
g_free(args->npub);
|
||||
g_free(args->pubkey);
|
||||
g_free(args->bunker);
|
||||
g_free(args->nsigner_transport);
|
||||
g_free(args->nsigner_device);
|
||||
g_free(args->nsigner_service);
|
||||
memset(args, 0, sizeof(*args));
|
||||
}
|
||||
|
||||
/* ── cli_parse ─────────────────────────────────────────────────────── */
|
||||
|
||||
int cli_parse(int *argc, char ***argv, cli_args_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->account = -1;
|
||||
out->nsigner_index = -1;
|
||||
|
||||
/* getopt_long reorders argv so non-options end up at the end. */
|
||||
opterr = 0; /* we print our own errors */
|
||||
optind = 1;
|
||||
|
||||
int c;
|
||||
int long_idx;
|
||||
while ((c = getopt_long(*argc, *argv, short_opts, long_opts, &long_idx)) != -1) {
|
||||
switch (c) {
|
||||
/* Short options */
|
||||
case 'v': out->verbose++; break;
|
||||
case 'V': out->want_version = TRUE; return 0;
|
||||
case 'h': out->want_help = TRUE; return 0;
|
||||
case 'q': out->quiet = TRUE; break;
|
||||
case '?':
|
||||
if (optopt) {
|
||||
fprintf(stderr, "[cli] unknown short option '-%c'\n", optopt);
|
||||
} else {
|
||||
fprintf(stderr, "[cli] unknown long option near '%s'\n",
|
||||
(*argv)[optind - 1] ? (*argv)[optind - 1] : "?");
|
||||
}
|
||||
cli_print_usage(stderr);
|
||||
return -1;
|
||||
|
||||
/* Browser / startup */
|
||||
case OPT_URL:
|
||||
add_string(&out->urls, &out->url_count, optarg);
|
||||
break;
|
||||
case OPT_NEW_TAB_URL:
|
||||
g_free(out->new_tab_url);
|
||||
out->new_tab_url = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NO_SESSION_RESTORE:
|
||||
out->session_restore = CLI_TRISTATE_FALSE;
|
||||
break;
|
||||
case OPT_SESSION_RESTORE:
|
||||
out->session_restore = CLI_TRISTATE_TRUE;
|
||||
break;
|
||||
case OPT_MAX_TABS:
|
||||
out->max_tabs = atoi(optarg);
|
||||
if (out->max_tabs < 1) {
|
||||
fprintf(stderr, "[cli] --max-tabs must be >= 1\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_VERSION:
|
||||
out->want_version = TRUE;
|
||||
return 0;
|
||||
|
||||
/* Agent server */
|
||||
case OPT_PORT:
|
||||
out->agent_port = atoi(optarg);
|
||||
if (out->agent_port < 1 || out->agent_port > 65535) {
|
||||
fprintf(stderr, "[cli] --port must be 1-65535\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NO_AGENT:
|
||||
out->agent_enabled = CLI_TRISTATE_FALSE;
|
||||
break;
|
||||
case OPT_AGENT:
|
||||
out->agent_enabled = CLI_TRISTATE_TRUE;
|
||||
break;
|
||||
case OPT_AGENT_ORIGIN:
|
||||
add_string(&out->agent_origins, &out->agent_origin_count, optarg);
|
||||
break;
|
||||
|
||||
/* Login */
|
||||
case OPT_LOGIN_METHOD:
|
||||
if (!valid_login_method(optarg)) {
|
||||
fprintf(stderr, "[cli] invalid --login-method '%s'\n", optarg);
|
||||
fprintf(stderr, "[cli] use: generate|local|seed|readonly|nip46|nsigner\n");
|
||||
return -1;
|
||||
}
|
||||
g_free(out->login_method);
|
||||
out->login_method = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSEC:
|
||||
g_free(out->nsec);
|
||||
out->nsec = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_PRIVKEY:
|
||||
g_free(out->privkey);
|
||||
out->privkey = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_MNEMONIC:
|
||||
g_free(out->mnemonic);
|
||||
out->mnemonic = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_ACCOUNT:
|
||||
out->account = atoi(optarg);
|
||||
if (out->account < 0) {
|
||||
fprintf(stderr, "[cli] --account must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NPUB:
|
||||
g_free(out->npub);
|
||||
out->npub = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_PUBKEY:
|
||||
g_free(out->pubkey);
|
||||
out->pubkey = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_BUNKER:
|
||||
g_free(out->bunker);
|
||||
out->bunker = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_TRANSPORT:
|
||||
g_free(out->nsigner_transport);
|
||||
out->nsigner_transport = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_DEVICE:
|
||||
g_free(out->nsigner_device);
|
||||
out->nsigner_device = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_SERVICE:
|
||||
g_free(out->nsigner_service);
|
||||
out->nsigner_service = xstrdup(optarg);
|
||||
break;
|
||||
case OPT_NSIGNER_INDEX:
|
||||
out->nsigner_index = atoi(optarg);
|
||||
if (out->nsigner_index < 0) {
|
||||
fprintf(stderr, "[cli] --nsigner-index must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NO_SAVE_IDENTITY:
|
||||
out->no_save_identity = TRUE;
|
||||
break;
|
||||
case OPT_NO_LOGIN:
|
||||
out->no_login = TRUE;
|
||||
break;
|
||||
case OPT_LOGIN_TIMEOUT:
|
||||
out->login_timeout_ms = atoi(optarg);
|
||||
if (out->login_timeout_ms < 0) {
|
||||
fprintf(stderr, "[cli] --login-timeout must be >= 0\n");
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
|
||||
/* Diagnostics */
|
||||
case OPT_VERBOSE:
|
||||
out->verbose++;
|
||||
break;
|
||||
case OPT_QUIET:
|
||||
out->quiet = TRUE;
|
||||
break;
|
||||
case OPT_HELP:
|
||||
out->want_help = TRUE;
|
||||
return 0;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[cli] unhandled option\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validate login-method-specific args. */
|
||||
if (out->login_method) {
|
||||
const char *m = out->login_method;
|
||||
if (strcmp(m, "local") == 0) {
|
||||
if (!out->nsec && !out->privkey) {
|
||||
fprintf(stderr, "[cli] --login-method local requires --nsec or --privkey\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "seed") == 0) {
|
||||
if (!out->mnemonic) {
|
||||
fprintf(stderr, "[cli] --login-method seed requires --mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "readonly") == 0) {
|
||||
if (!out->npub && !out->pubkey) {
|
||||
fprintf(stderr, "[cli] --login-method readonly requires --npub or --pubkey\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "nip46") == 0) {
|
||||
if (!out->bunker) {
|
||||
fprintf(stderr, "[cli] --login-method nip46 requires --bunker\n");
|
||||
return -1;
|
||||
}
|
||||
} else if (strcmp(m, "nsigner") == 0) {
|
||||
if (!out->nsigner_transport || !out->nsigner_device) {
|
||||
fprintf(stderr, "[cli] --login-method nsigner requires --nsigner-transport and --nsigner-device\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
/* "generate" needs no extra args. */
|
||||
}
|
||||
|
||||
/* Collect positional args (after getopt_long reordering) as URLs. */
|
||||
for (int i = optind; i < *argc; i++) {
|
||||
add_string(&out->urls, &out->url_count, (*argv)[i]);
|
||||
}
|
||||
|
||||
/* Repack argv: keep argv[0], then positional args only. gtk_init()
|
||||
* will see a clean vector with no unknown options. */
|
||||
int new_argc = 1; /* argv[0] */
|
||||
for (int i = optind; i < *argc; i++) {
|
||||
(*argv)[new_argc++] = (*argv)[i];
|
||||
}
|
||||
(*argv)[new_argc] = NULL;
|
||||
*argc = new_argc;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── cli_login ─────────────────────────────────────────────────────── */
|
||||
|
||||
int cli_login(const cli_args_t *args) {
|
||||
if (args->login_method == NULL) {
|
||||
fprintf(stderr, "[cli] cli_login called with no --login-method\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Build the cJSON params object matching agent_login.h's contract. */
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(params, "method", args->login_method);
|
||||
|
||||
if (args->nsec) cJSON_AddStringToObject(params, "nsec", args->nsec);
|
||||
if (args->privkey) cJSON_AddStringToObject(params, "privkey_hex", args->privkey);
|
||||
if (args->mnemonic) cJSON_AddStringToObject(params, "mnemonic", args->mnemonic);
|
||||
if (args->account >= 0) cJSON_AddNumberToObject(params, "account", args->account);
|
||||
if (args->npub) cJSON_AddStringToObject(params, "npub", args->npub);
|
||||
if (args->pubkey) cJSON_AddStringToObject(params, "pubkey_hex", args->pubkey);
|
||||
if (args->bunker) cJSON_AddStringToObject(params, "bunker_url", args->bunker);
|
||||
if (args->nsigner_transport) cJSON_AddStringToObject(params, "transport", args->nsigner_transport);
|
||||
if (args->nsigner_device) cJSON_AddStringToObject(params, "device", args->nsigner_device);
|
||||
if (args->nsigner_service) cJSON_AddStringToObject(params, "service", args->nsigner_service);
|
||||
if (args->nsigner_index >= 0) cJSON_AddNumberToObject(params, "index", args->nsigner_index);
|
||||
|
||||
/* agent_login() calls app_set_signer() on success, which sets the
|
||||
* same global state the GTK dialog would. It also handles
|
||||
* nostr_init() internally. */
|
||||
cJSON *result = agent_login(params);
|
||||
cJSON_Delete(params);
|
||||
|
||||
if (result == NULL) {
|
||||
fprintf(stderr, "[cli] login returned NULL\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *success = cJSON_GetObjectItem(result, "success");
|
||||
if (!success || !cJSON_IsTrue(success)) {
|
||||
cJSON *err = cJSON_GetObjectItem(result, "error");
|
||||
const char *code = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "code")) : "?";
|
||||
const char *msg = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "message")) : "unknown";
|
||||
fprintf(stderr, "[cli] login failed: %s: %s\n", code ? code : "?", msg ? msg : "?");
|
||||
cJSON_Delete(result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Login succeeded. Print the pubkey for visibility. */
|
||||
cJSON *data = cJSON_GetObjectItem(result, "data");
|
||||
if (data) {
|
||||
const char *pubkey = cJSON_GetStringValue(cJSON_GetObjectItem(data, "pubkey"));
|
||||
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(data, "method"));
|
||||
g_print("[cli] Logged in: method=%s pubkey=%s\n",
|
||||
method ? method : "?", pubkey ? pubkey : "?");
|
||||
}
|
||||
cJSON_Delete(result);
|
||||
|
||||
/* TODO: persist identity via key_store_save() when that path is
|
||||
* wired up. For now --no-save-identity is accepted but the save
|
||||
* itself is a no-op (key_store_save is not called anywhere yet). */
|
||||
(void)args->no_save_identity;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* cli.h — command-line flag parsing for sovereign_browser
|
||||
*
|
||||
* Parses argv before gtk_init() so GTK doesn't abort on our flags.
|
||||
* Recognized flags are stripped from argv/argc so gtk_init() only sees
|
||||
* positional URLs. CLI flags override settings.conf for this run only
|
||||
* (nothing is written to disk).
|
||||
*
|
||||
* Login flags build a cJSON params object and call agent_login(),
|
||||
* reusing the same code path as the MCP 'login' tool. On success the
|
||||
* GTK login dialog is skipped entirely.
|
||||
*/
|
||||
|
||||
#ifndef CLI_H
|
||||
#define CLI_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdio.h> /* FILE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Tri-state for boolean overrides. */
|
||||
typedef enum {
|
||||
CLI_TRISTATE_UNSET = 0,
|
||||
CLI_TRISTATE_TRUE,
|
||||
CLI_TRISTATE_FALSE
|
||||
} cli_tristate_t;
|
||||
|
||||
/* Parsed command-line arguments. Zero-initialized by cli_parse().
|
||||
* Strings are heap-allocated and owned by the cli_args_t — free with
|
||||
* cli_args_free(). */
|
||||
typedef struct {
|
||||
/* Startup URLs: --url flags + positional args, in order. */
|
||||
char **urls;
|
||||
int url_count;
|
||||
|
||||
/* Browser overrides (NULL / 0 = not set). */
|
||||
char *new_tab_url; /* --new-tab-url */
|
||||
int max_tabs; /* --max-tabs (0 = unset) */
|
||||
cli_tristate_t session_restore; /* --session-restore / --no-session-restore */
|
||||
|
||||
/* Agent server overrides. */
|
||||
int agent_port; /* --port (0 = unset) */
|
||||
cli_tristate_t agent_enabled; /* --agent / --no-agent */
|
||||
char **agent_origins; /* --agent-origin (repeatable) */
|
||||
int agent_origin_count;
|
||||
|
||||
/* Login. login_method == NULL means "show GTK dialog".
|
||||
* no_login == TRUE means skip login entirely (browse without identity). */
|
||||
gboolean no_login; /* --no-login */
|
||||
char *login_method; /* --login-method */
|
||||
char *nsec; /* --nsec */
|
||||
char *privkey; /* --privkey */
|
||||
char *mnemonic; /* --mnemonic */
|
||||
int account; /* --account (-1 = unset, defaults to 0) */
|
||||
char *npub; /* --npub */
|
||||
char *pubkey; /* --pubkey */
|
||||
char *bunker; /* --bunker */
|
||||
char *nsigner_transport; /* --nsigner-transport */
|
||||
char *nsigner_device; /* --nsigner-device */
|
||||
char *nsigner_service; /* --nsigner-service */
|
||||
int nsigner_index; /* --nsigner-index (-1 = unset) */
|
||||
gboolean no_save_identity; /* --no-save-identity */
|
||||
int login_timeout_ms; /* --login-timeout (0 = unset) */
|
||||
|
||||
/* Diagnostics. */
|
||||
int verbose; /* --verbose / -v (repeatable count) */
|
||||
gboolean quiet; /* --quiet / -q */
|
||||
|
||||
/* Exit-request flags set by --help / --version. */
|
||||
gboolean want_help;
|
||||
gboolean want_version;
|
||||
} cli_args_t;
|
||||
|
||||
/*
|
||||
* Parse command-line arguments.
|
||||
*
|
||||
* On entry, *argc / *argv are the raw values from main(). On return,
|
||||
* recognized flags are removed from *argv / *argc (repacked via
|
||||
* getopt_long's optind) so the remaining vector is safe to pass to
|
||||
* gtk_init(). Positional URL arguments are preserved.
|
||||
*
|
||||
* Returns:
|
||||
* 0 — parse OK (or --help / --version requested; check args.want_*).
|
||||
* -1 — parse error (message printed to stderr).
|
||||
*
|
||||
* On error or when want_help/want_version is true, the caller should
|
||||
* exit without calling gtk_init().
|
||||
*/
|
||||
int cli_parse(int *argc, char ***argv, cli_args_t *out);
|
||||
|
||||
/*
|
||||
* Free heap-allocated fields in a cli_args_t. Safe to call on a
|
||||
* zero-initialized struct.
|
||||
*/
|
||||
void cli_args_free(cli_args_t *args);
|
||||
|
||||
/*
|
||||
* Print usage text to the given stream.
|
||||
*/
|
||||
void cli_print_usage(FILE *fp);
|
||||
|
||||
/*
|
||||
* Perform CLI login by building a cJSON params object from args and
|
||||
* calling agent_login(). Requires nostr_init() to have been called
|
||||
* already (agent_login() handles re-init internally).
|
||||
*
|
||||
* Returns:
|
||||
* 0 — login succeeded (global app state is now set).
|
||||
* -1 — login failed (error message printed to stderr).
|
||||
*
|
||||
* If args->no_save_identity is FALSE, the identity is persisted to
|
||||
* ~/.sovereign_browser/identity.json via key_store_save() on success.
|
||||
*/
|
||||
int cli_login(const cli_args_t *args);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CLI_H */
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* db.c — SQLite storage for sovereign_browser
|
||||
*
|
||||
* Stores Nostr events and misc data in ~/.sovereign_browser/browser.db.
|
||||
* Uses SQLITE_OPEN_FULLMUTEX for thread safety (the relay fetch runs in
|
||||
* a background thread).
|
||||
*/
|
||||
|
||||
#include "db.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ── Global state ──────────────────────────────────────────────────── */
|
||||
|
||||
static sqlite3 *g_db = NULL;
|
||||
|
||||
/* Static buffer for db_kv_get() — valid until the next call. */
|
||||
static char g_kv_buf[4096];
|
||||
|
||||
/* ── Path helper ───────────────────────────────────────────────────── */
|
||||
|
||||
static int db_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/browser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Schema ────────────────────────────────────────────────────────── */
|
||||
|
||||
static const char *SCHEMA_SQL =
|
||||
"CREATE TABLE IF NOT EXISTS events ("
|
||||
" id TEXT PRIMARY KEY,"
|
||||
" pubkey TEXT NOT NULL,"
|
||||
" kind INTEGER NOT NULL,"
|
||||
" created_at INTEGER NOT NULL,"
|
||||
" content TEXT,"
|
||||
" sig TEXT,"
|
||||
" raw_json TEXT NOT NULL,"
|
||||
" fetched_at INTEGER NOT NULL"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS event_tags ("
|
||||
" event_id TEXT NOT NULL,"
|
||||
" tag_name TEXT NOT NULL,"
|
||||
" tag_value TEXT,"
|
||||
" position INTEGER NOT NULL,"
|
||||
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS key_value ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
|
||||
" ON events(pubkey, kind, created_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
|
||||
" ON event_tags(tag_name, tag_value);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
|
||||
" ON event_tags(event_id);";
|
||||
|
||||
/* ── Init / Close ──────────────────────────────────────────────────── */
|
||||
|
||||
int db_init(void) {
|
||||
char path[512];
|
||||
if (db_path(path, sizeof(path)) != 0) {
|
||||
g_printerr("[db] Failed to get database path\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = sqlite3_open_v2(path, &g_db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
|
||||
SQLITE_OPEN_FULLMUTEX,
|
||||
NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to open database: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Set busy timeout to 5 seconds (in case of contention). */
|
||||
sqlite3_busy_timeout(g_db, 5000);
|
||||
|
||||
/* Enable foreign keys (for ON DELETE CASCADE on event_tags). */
|
||||
char *err = NULL;
|
||||
rc = sqlite3_exec(g_db, "PRAGMA foreign_keys = ON;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to enable foreign keys: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
}
|
||||
|
||||
/* Create schema. */
|
||||
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to create schema: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[db] Database initialized at %s\n", path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void db_close(void) {
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
g_print("[db] Database closed\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Events ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Helper: get a string field from a cJSON event, or "" if missing. */
|
||||
static const char *event_str(const cJSON *event, const char *field) {
|
||||
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
|
||||
if (cJSON_IsString(item)) return item->valuestring;
|
||||
return "";
|
||||
}
|
||||
|
||||
/* Helper: get an integer field from a cJSON event, or 0 if missing. */
|
||||
static long event_int(const cJSON *event, const char *field) {
|
||||
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
|
||||
if (cJSON_IsNumber(item)) return (long)item->valuedouble;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_store_event(const cJSON *event) {
|
||||
if (g_db == NULL || event == NULL) return -1;
|
||||
|
||||
const char *id = event_str(event, "id");
|
||||
const char *pubkey = event_str(event, "pubkey");
|
||||
long kind = event_int(event, "kind");
|
||||
long created = event_int(event, "created_at");
|
||||
const char *content = event_str(event, "content");
|
||||
const char *sig = event_str(event, "sig");
|
||||
|
||||
if (id[0] == '\0' || pubkey[0] == '\0') {
|
||||
g_printerr("[db] Event missing id or pubkey\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Serialize the full event to JSON for round-tripping. */
|
||||
char *raw_json = cJSON_PrintUnformatted(event);
|
||||
if (raw_json == NULL) return -1;
|
||||
|
||||
/* Begin transaction. */
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] BEGIN failed: %s\n", err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Upsert the event (INSERT OR REPLACE). */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT OR REPLACE INTO events "
|
||||
"(id, pubkey, kind, created_at, content, sig, raw_json, fetched_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'));",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare events insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, kind);
|
||||
sqlite3_bind_int64(stmt, 4, created);
|
||||
sqlite3_bind_text(stmt, 5, content, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, sig, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 7, raw_json, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] Events insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete old tags for this event (if replacing) and insert new ones. */
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"DELETE FROM event_tags WHERE event_id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
/* Insert tags. */
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (cJSON_IsArray(tags)) {
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO event_tags (event_id, tag_name, tag_value, position) "
|
||||
"VALUES (?, ?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc == SQLITE_OK) {
|
||||
int pos = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *name_item = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *value_item = cJSON_GetArrayItem(tag, 1);
|
||||
const char *tag_name = (name_item && cJSON_IsString(name_item))
|
||||
? name_item->valuestring : "";
|
||||
const char *tag_value = (value_item && cJSON_IsString(value_item))
|
||||
? value_item->valuestring : "";
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, tag_name, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, tag_value, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, pos);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_reset(stmt);
|
||||
pos++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
/* Commit. */
|
||||
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] COMMIT failed: %s\n", err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
free(raw_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(raw_json);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *db_get_latest_event(const char *pubkey_hex, int kind) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT raw_json FROM events "
|
||||
"WHERE pubkey = ? AND kind = ? "
|
||||
"ORDER BY created_at DESC LIMIT 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare latest_event failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
|
||||
cJSON *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (json_str) {
|
||||
result = cJSON_Parse(json_str);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
/* If limit is 0, use a large default. */
|
||||
int lim = (limit > 0) ? limit : 10000;
|
||||
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT raw_json FROM events "
|
||||
"WHERE pubkey = ? AND kind = ? "
|
||||
"ORDER BY created_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare get_events failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
sqlite3_bind_int(stmt, 3, lim);
|
||||
|
||||
cJSON *array = cJSON_CreateArray();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (json_str) {
|
||||
cJSON *event = cJSON_Parse(json_str);
|
||||
if (event) {
|
||||
cJSON_AddItemToArray(array, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return array;
|
||||
}
|
||||
|
||||
int db_count_events(const char *pubkey_hex, int kind) {
|
||||
if (g_db == NULL || pubkey_hex == NULL) return -1;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT COUNT(*) FROM events WHERE pubkey = ? AND kind = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, kind);
|
||||
|
||||
int count = -1;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
count = sqlite3_column_int(stmt, 0);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
int db_kv_set(const char *key, const char *value) {
|
||||
if (g_db == NULL || key == NULL || value == NULL) return -1;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
const char *db_kv_get(const char *key) {
|
||||
if (g_db == NULL || key == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT value FROM key_value WHERE key = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return NULL;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
|
||||
const char *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *val = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (val) {
|
||||
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
|
||||
result = g_kv_buf;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *db_kv_get_copy(const char *key) {
|
||||
const char *val = db_kv_get(key);
|
||||
if (val == NULL) return NULL;
|
||||
return g_strdup(val);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* db.h — SQLite storage for sovereign_browser
|
||||
*
|
||||
* Provides persistent local storage for Nostr events and misc data.
|
||||
* The database lives at ~/.sovereign_browser/browser.db.
|
||||
*
|
||||
* Tables:
|
||||
* events — Nostr events (id, pubkey, kind, created_at, content, sig, raw_json)
|
||||
* event_tags — Tag rows for querying by tag name/value
|
||||
* key_value — Simple key-value store for misc settings/cache
|
||||
*
|
||||
* Thread safety: the database is opened with SQLITE_OPEN_FULLMUTEX, so
|
||||
* calls from multiple threads are safe (serialized by SQLite's mutex).
|
||||
*/
|
||||
|
||||
#ifndef DB_H
|
||||
#define DB_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* cJSON is in the vendored nostr_core_lib */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Initialize the database at ~/.sovereign_browser/browser.db.
|
||||
* Creates tables and indexes if they don't exist.
|
||||
* Call once at startup (after settings_load()).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init(void);
|
||||
|
||||
/*
|
||||
* Close the database. Call at shutdown.
|
||||
*/
|
||||
void db_close(void);
|
||||
|
||||
/* ── Events ────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Store a Nostr event (upsert — replaces if same event_id exists).
|
||||
* Parses the cJSON event and stores it in the events + event_tags tables.
|
||||
*
|
||||
* event — a cJSON object representing a Nostr event with at least:
|
||||
* id, pubkey, kind, created_at, content, sig, tags
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_store_event(const cJSON *event);
|
||||
|
||||
/*
|
||||
* Fetch the most recent event of a given kind for a pubkey.
|
||||
*
|
||||
* Returns a newly allocated cJSON event (parsed from raw_json), or NULL
|
||||
* if not found. Caller must cJSON_Delete() the result.
|
||||
*/
|
||||
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
|
||||
|
||||
/*
|
||||
* Fetch all events of a given kind for a pubkey, newest first.
|
||||
*
|
||||
* limit — max number of events (0 = no limit)
|
||||
*
|
||||
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
|
||||
* Returns NULL on error.
|
||||
*/
|
||||
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
|
||||
|
||||
/*
|
||||
* Count events of a given kind for a pubkey.
|
||||
* Returns the count, or -1 on error.
|
||||
*/
|
||||
int db_count_events(const char *pubkey_hex, int kind);
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Set a key-value pair (upsert).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set(const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Get a value by key.
|
||||
* Returns a pointer to the value string, or NULL if not found.
|
||||
* The pointer is valid until the next db_kv_get() call (uses a static
|
||||
* buffer). Call db_kv_get_copy() if you need a persistent copy.
|
||||
*/
|
||||
const char *db_kv_get(const char *key);
|
||||
|
||||
/*
|
||||
* Get a value by key, returning a newly allocated copy.
|
||||
* Caller must g_free() the result. Returns NULL if not found.
|
||||
*/
|
||||
char *db_kv_get_copy(const char *key);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DB_H */
|
||||
+7
-2
@@ -98,8 +98,13 @@ void history_add(const char *url) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Skip sovereign:// internal pages. */
|
||||
if (strncmp(url, "sovereign://", 12) == 0) {
|
||||
/* Skip sovereign:// API endpoints (not pages the user would want
|
||||
* in their recents). The actual pages — sovereign://settings,
|
||||
* sovereign://profile, sovereign://security — are kept in history
|
||||
* so the user can quickly return to them. */
|
||||
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
|
||||
strncmp(url, "sovereign://qr", 14) == 0 ||
|
||||
strncmp(url, "sovereign://nostr/", 18) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
|
||||
+147
-167
@@ -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]
|
||||
*/
|
||||
@@ -42,6 +43,9 @@
|
||||
#include "session.h"
|
||||
#include "agent_server.h"
|
||||
#include "agent_login.h"
|
||||
#include "cli.h"
|
||||
#include "db.h"
|
||||
#include "relay_fetch.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -189,166 +193,24 @@ 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 --------------------------------------------- */
|
||||
@@ -443,6 +305,7 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
nostr_cleanup();
|
||||
db_close();
|
||||
gtk_main_quit();
|
||||
}
|
||||
|
||||
@@ -480,11 +343,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 +361,73 @@ 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();
|
||||
|
||||
const char *start_url = (argc > 1) ? argv[1] : NULL;
|
||||
/* Initialize the SQLite database for Nostr events and misc data. */
|
||||
db_init();
|
||||
|
||||
/* Apply CLI overrides to the in-memory settings singleton. These
|
||||
* do not write to disk — they are one-shot overrides for this run. */
|
||||
{
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
if (cli.new_tab_url) {
|
||||
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
|
||||
cli.new_tab_url);
|
||||
}
|
||||
if (cli.max_tabs > 0) {
|
||||
s->max_tabs = cli.max_tabs;
|
||||
}
|
||||
if (cli.session_restore == CLI_TRISTATE_TRUE) {
|
||||
s->restore_session = TRUE;
|
||||
} else if (cli.session_restore == CLI_TRISTATE_FALSE) {
|
||||
s->restore_session = FALSE;
|
||||
}
|
||||
if (cli.agent_port > 0) {
|
||||
s->agent_server_port = cli.agent_port;
|
||||
}
|
||||
if (cli.agent_enabled == CLI_TRISTATE_TRUE) {
|
||||
s->agent_server_enabled = TRUE;
|
||||
} else if (cli.agent_enabled == CLI_TRISTATE_FALSE) {
|
||||
s->agent_server_enabled = FALSE;
|
||||
}
|
||||
if (cli.login_timeout_ms > 0) {
|
||||
s->agent_login_timeout_ms = cli.login_timeout_ms;
|
||||
}
|
||||
/* --agent-origin: append to allowed origins (comma-separated). */
|
||||
if (cli.agent_origin_count > 0) {
|
||||
GString *origins = g_string_new(s->agent_allowed_origins);
|
||||
for (int i = 0; i < cli.agent_origin_count; i++) {
|
||||
if (origins->len > 0) g_string_append_c(origins, ',');
|
||||
g_string_append(origins, cli.agent_origins[i]);
|
||||
}
|
||||
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins),
|
||||
"%s", origins->str);
|
||||
g_string_free(origins, TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
/* Top-level window. */
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
@@ -529,6 +457,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 +465,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 +501,22 @@ 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);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -593,15 +557,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
|
||||
@@ -616,6 +594,8 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
gtk_main();
|
||||
|
||||
|
||||
+671
-44
@@ -8,12 +8,18 @@
|
||||
#include "nostr_bridge.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "nostr_core/nip019.h"
|
||||
#include "nostr_core/nip021.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
#include "settings.h"
|
||||
#include "tab_manager.h"
|
||||
#include "qr.h"
|
||||
#include "db.h"
|
||||
|
||||
/* ── Global bridge state ────────────────────────────────────────── *
|
||||
* The URI scheme callback needs access to the current signer. We keep
|
||||
@@ -93,6 +99,22 @@ static void respond_error_json(WebKitURISchemeRequest *request,
|
||||
g_object_unref(stream);
|
||||
}
|
||||
|
||||
/* Finish a scheme request with raw bytes and a given MIME type.
|
||||
* Takes ownership of the GBytes (unrefs it after finishing). */
|
||||
static void respond_bytes(WebKitURISchemeRequest *request, GBytes *bytes,
|
||||
const char *mime_type) {
|
||||
if (bytes == NULL) {
|
||||
respond_error_json(request, -1, "Failed to generate content");
|
||||
return;
|
||||
}
|
||||
gsize len = g_bytes_get_size(bytes);
|
||||
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
|
||||
g_bytes_unref(bytes);
|
||||
|
||||
webkit_uri_scheme_request_finish(request, stream, len, mime_type);
|
||||
g_object_unref(stream);
|
||||
}
|
||||
|
||||
/* ── Method handlers ────────────────────────────────────────────── */
|
||||
|
||||
static void handle_get_public_key(WebKitURISchemeRequest *request) {
|
||||
@@ -337,11 +359,38 @@ static void handle_get_relays(WebKitURISchemeRequest *request) {
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* ── Security settings page ────────────────────────────────────── */
|
||||
/* ── Settings page ─────────────────────────────────────────────── */
|
||||
|
||||
/* Helper: extract a query parameter value (key=...) from a query string.
|
||||
* Returns a newly allocated string (g_free) or NULL if not found. */
|
||||
static char *query_param(const char *query, const char *key) {
|
||||
if (query == NULL || key == NULL) return NULL;
|
||||
size_t klen = strlen(key);
|
||||
const char *p = query;
|
||||
while (*p) {
|
||||
if (strncmp(p, key, klen) == 0 && p[klen] == '=') {
|
||||
const char *v = p + klen + 1;
|
||||
const char *end = strchr(v, '&');
|
||||
size_t vlen = end ? (size_t)(end - v) : strlen(v);
|
||||
char *val = g_malloc(vlen + 1);
|
||||
memcpy(val, v, vlen);
|
||||
val[vlen] = '\0';
|
||||
/* URL-decode. */
|
||||
char *decoded = g_uri_unescape_string(val, NULL);
|
||||
g_free(val);
|
||||
return decoded;
|
||||
}
|
||||
/* Advance to next param. */
|
||||
const char *amp = strchr(p, '&');
|
||||
if (amp == NULL) break;
|
||||
p = amp + 1;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Generate the sovereign://settings HTML page. */
|
||||
static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
/* Read current settings state. */
|
||||
/* Read current WebKit security settings state. */
|
||||
int dev_extras = g_bridge.settings ?
|
||||
webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0;
|
||||
int file_access = g_bridge.settings ?
|
||||
@@ -349,6 +398,54 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
int universal_access = g_bridge.settings ?
|
||||
webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings) : 0;
|
||||
|
||||
/* Read browser preferences. */
|
||||
const browser_settings_t *bs = settings_get();
|
||||
const char *pos_str = "top";
|
||||
switch (bs->tab_bar_position) {
|
||||
case GTK_POS_TOP: pos_str = "top"; break;
|
||||
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
|
||||
case GTK_POS_LEFT: pos_str = "left"; break;
|
||||
case GTK_POS_RIGHT: pos_str = "right"; break;
|
||||
}
|
||||
|
||||
/* Build a nostr:npub1... URI for the QR code in the Identity section.
|
||||
* Convert the hex pubkey to 32 bytes, then use nip021 to build the URI.
|
||||
* If no identity is loaded, skip the QR code. */
|
||||
char npub_uri[160] = "";
|
||||
char qr_img_html[512] = "";
|
||||
if (g_bridge.pubkey_hex[0] != '\0') {
|
||||
unsigned char pubkey_bytes[32] = {0};
|
||||
int valid = 1;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
unsigned int byte;
|
||||
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
|
||||
valid = 0;
|
||||
break;
|
||||
}
|
||||
pubkey_bytes[i] = (unsigned char)byte;
|
||||
}
|
||||
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
|
||||
sizeof(npub_uri)) == 0) {
|
||||
/* URL-encode the nostr: URI for use in an <img src> query. */
|
||||
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
|
||||
snprintf(qr_img_html, sizeof(qr_img_html),
|
||||
" <div style='text-align:center; margin: 12px 0;'>"
|
||||
"<img src='sovereign://qr?text=%s&box_size=6&border=4' "
|
||||
"alt='npub QR code' style='image-rendering: pixelated; "
|
||||
"border: 4px solid #fff; border-radius: 4px;'>"
|
||||
"<div class='info' style='margin-top: 6px;'>"
|
||||
"Scan with a Nostr client to load this identity</div>"
|
||||
"</div>\n",
|
||||
enc);
|
||||
g_free(enc);
|
||||
}
|
||||
}
|
||||
|
||||
/* HTML-escape the bootstrap relays for the <textarea>. Newlines are
|
||||
* preserved (they're valid in textarea content), but <, >, & are
|
||||
* escaped to prevent breaking the HTML. */
|
||||
char *relays_escaped = g_markup_escape_text(bs->bootstrap_relays, -1);
|
||||
|
||||
/* Build the HTML page. */
|
||||
char *html = g_strdup_printf(
|
||||
"<!DOCTYPE html>\n"
|
||||
@@ -373,6 +470,18 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
" width: 20px; height: 20px; border-radius: 50%%; background: #e0e0e0;\n"
|
||||
" transition: transform 0.2s; }\n"
|
||||
" .toggle.on::after { transform: translateX(26px); }\n"
|
||||
" .field { display: flex; justify-content: space-between;\n"
|
||||
" align-items: center; padding: 12px 0;\n"
|
||||
" border-bottom: 1px solid #333; }\n"
|
||||
" .field input, .field select {\n"
|
||||
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
|
||||
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
|
||||
" font-size: 13px; min-width: 200px; }\n"
|
||||
" .field input:focus, .field select:focus { border-color: #ff4444; }\n"
|
||||
" .save-btn { background: #ff4444; color: #fff; border: none;\n"
|
||||
" border-radius: 4px; padding: 6px 16px; cursor: pointer;\n"
|
||||
" font-family: monospace; font-size: 13px; margin-left: 8px; }\n"
|
||||
" .save-btn:hover { background: #ff6666; }\n"
|
||||
" .status { padding: 8px; margin: 10px 0; border-radius: 4px; display: none; }\n"
|
||||
" .status.show { display: block; }\n"
|
||||
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
|
||||
@@ -388,6 +497,104 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
" <div class='info-row'><span>Pubkey</span><span class='info'>%.16s…</span></div>\n"
|
||||
" <div class='info-row'><span>Method</span><span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Signing</span><span class='info'>%s</span></div>\n"
|
||||
"%s"
|
||||
"\n"
|
||||
"<h2>Tabs</h2>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Restore session on startup</div>\n"
|
||||
" <div class='setting-desc'>Reopen tabs from the previous session</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"restore_session\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>New tab URL</div>\n"
|
||||
" <div class='setting-desc'>Page loaded when opening a new tab</div></div>\n"
|
||||
" <div><input type='text' id='new_tab_url' value='%s'>\n"
|
||||
" <button class='save-btn' onclick=\"save('new_tab_url')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Tab bar position</div>\n"
|
||||
" <div class='setting-desc'>Where the tab strip appears</div></div>\n"
|
||||
" <div><select id='tab_bar_position'>\n"
|
||||
" <option value='top'%s>Top</option>\n"
|
||||
" <option value='bottom'%s>Bottom</option>\n"
|
||||
" <option value='left'%s>Left</option>\n"
|
||||
" <option value='right'%s>Right</option>\n"
|
||||
" </select>\n"
|
||||
" <button class='save-btn' onclick=\"save('tab_bar_position')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Show tab close buttons</div>\n"
|
||||
" <div class='setting-desc'>Per-tab close button in the tab strip</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"show_tab_close_buttons\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Middle-click to close tab</div>\n"
|
||||
" <div class='setting-desc'>Middle-click on a tab closes it</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"middle_click_close\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Ctrl+Tab to switch tabs</div>\n"
|
||||
" <div class='setting-desc'>Cycle tabs with Ctrl+Tab / Ctrl+Shift+Tab</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"ctrl_tab_switch\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Allow drag to reorder tabs</div>\n"
|
||||
" <div class='setting-desc'>Drag tabs in the strip to reorder them</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"tab_drag_reorder\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Maximum tabs</div>\n"
|
||||
" <div class='setting-desc'>Hard limit on simultaneous tabs</div></div>\n"
|
||||
" <div><input type='number' id='max_tabs' value='%d' min='1' max='999'>\n"
|
||||
" <button class='save-btn' onclick=\"save('max_tabs')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<h2>Agent Server</h2>\n"
|
||||
"\n"
|
||||
"<div class='setting'>\n"
|
||||
" <div><div class='setting-name'>Enable agent server</div>\n"
|
||||
" <div class='setting-desc'>MCP server for automation (takes effect on next launch)</div></div>\n"
|
||||
" <div class='toggle %s' onclick='toggle(\"agent_server_enabled\")'></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Agent server port</div>\n"
|
||||
" <div class='setting-desc'>TCP port for the MCP server (next launch)</div></div>\n"
|
||||
" <div><input type='number' id='agent_server_port' value='%d' min='1' max='65535'>\n"
|
||||
" <button class='save-btn' onclick=\"save('agent_server_port')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Allowed origins</div>\n"
|
||||
" <div class='setting-desc'>Comma-separated CORS origins (* for any)</div></div>\n"
|
||||
" <div><input type='text' id='agent_allowed_origins' value='%s'>\n"
|
||||
" <button class='save-btn' onclick=\"save('agent_allowed_origins')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Login timeout (ms)</div>\n"
|
||||
" <div class='setting-desc'>Wait for agent login before GTK dialog</div></div>\n"
|
||||
" <div><input type='number' id='agent_login_timeout_ms' value='%d' min='0' max='300000'>\n"
|
||||
" <button class='save-btn' onclick=\"save('agent_login_timeout_ms')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<h2>Bootstrap Relays</h2>\n"
|
||||
"<p class='note'>Relays queried after login to fetch your profile (kind 0),\n"
|
||||
"contacts (kind 3), and relay list (kind 10002). One wss:// URL per line.</p>\n"
|
||||
"<div class='field'>\n"
|
||||
" <div><div class='setting-name'>Relay URLs</div>\n"
|
||||
" <div class='setting-desc'>One wss:// URL per line</div></div>\n"
|
||||
" <div><textarea id='bootstrap_relays' rows='4' cols='40'>%s</textarea>\n"
|
||||
" <button class='save-btn' onclick=\"save('bootstrap_relays')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<h2>Security</h2>\n"
|
||||
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
|
||||
@@ -427,31 +634,62 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
"<div id='status' class='status'></div>\n"
|
||||
"\n"
|
||||
"<script>\n"
|
||||
" function showStatus(cls, msg) {\n"
|
||||
" var s = document.getElementById('status');\n"
|
||||
" s.className = 'status ' + cls + ' show';\n"
|
||||
" s.textContent = msg;\n"
|
||||
" }\n"
|
||||
" function toggle(feature) {\n"
|
||||
" fetch('sovereign://settings/set?feature=' + feature)\n"
|
||||
" .then(r => r.json())\n"
|
||||
" .then(d => {\n"
|
||||
" var s = document.getElementById('status');\n"
|
||||
" if (d.error) {\n"
|
||||
" s.className = 'status err show';\n"
|
||||
" s.textContent = d.message;\n"
|
||||
" showStatus('err', d.message);\n"
|
||||
" } else {\n"
|
||||
" s.className = 'status ok show';\n"
|
||||
" s.textContent = d.feature + ': ' + (d.enabled ? 'ON' : 'OFF');\n"
|
||||
" showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF'));\n"
|
||||
" setTimeout(() => location.reload(), 500);\n"
|
||||
" }\n"
|
||||
" })\n"
|
||||
" .catch(e => {\n"
|
||||
" var s = document.getElementById('status');\n"
|
||||
" s.className = 'status err show';\n"
|
||||
" s.textContent = 'Error: ' + e.message;\n"
|
||||
" });\n"
|
||||
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
|
||||
" }\n"
|
||||
" function save(key) {\n"
|
||||
" var el = document.getElementById(key);\n"
|
||||
" var val = el ? el.value : '';\n"
|
||||
" fetch('sovereign://settings/set?key=' + encodeURIComponent(key)\n"
|
||||
" + '&value=' + encodeURIComponent(val))\n"
|
||||
" .then(r => r.json())\n"
|
||||
" .then(d => {\n"
|
||||
" if (d.error) {\n"
|
||||
" showStatus('err', d.message);\n"
|
||||
" } else {\n"
|
||||
" showStatus('ok', d.key + ' = ' + d.value);\n"
|
||||
" if (d.reload) setTimeout(() => location.reload(), 500);\n"
|
||||
" }\n"
|
||||
" })\n"
|
||||
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
|
||||
" }\n"
|
||||
"</script>\n"
|
||||
"</body></html>\n",
|
||||
g_bridge.pubkey_hex[0] ? g_bridge.pubkey_hex : "(none)",
|
||||
g_bridge.readonly ? "read-only" : "signing",
|
||||
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
|
||||
qr_img_html,
|
||||
bs->restore_session ? "on" : "off",
|
||||
bs->new_tab_url,
|
||||
strcmp(pos_str, "top") == 0 ? " selected" : "",
|
||||
strcmp(pos_str, "bottom") == 0 ? " selected" : "",
|
||||
strcmp(pos_str, "left") == 0 ? " selected" : "",
|
||||
strcmp(pos_str, "right") == 0 ? " selected" : "",
|
||||
bs->show_tab_close_buttons ? "on" : "off",
|
||||
bs->middle_click_close ? "on" : "off",
|
||||
bs->ctrl_tab_switch ? "on" : "off",
|
||||
bs->tab_drag_reorder ? "on" : "off",
|
||||
bs->max_tabs,
|
||||
bs->agent_server_enabled ? "on" : "off",
|
||||
bs->agent_server_port,
|
||||
bs->agent_allowed_origins,
|
||||
bs->agent_login_timeout_ms,
|
||||
relays_escaped,
|
||||
dev_extras ? "on" : "off",
|
||||
file_access ? "on" : "off",
|
||||
universal_access ? "on" : "off"
|
||||
@@ -459,59 +697,405 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
|
||||
|
||||
respond_html(request, html);
|
||||
g_free(html);
|
||||
g_free(relays_escaped);
|
||||
}
|
||||
|
||||
/* Handle sovereign://settings/set?feature=X — toggle a setting. */
|
||||
/* Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y). */
|
||||
static void handle_settings_set(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
if (g_bridge.settings == NULL) {
|
||||
respond_error_json(request, -1, "Settings not available");
|
||||
browser_settings_t *bs = settings_get_mutable();
|
||||
|
||||
/* ── Toggle path: ?feature=X ──────────────────────────────────── */
|
||||
char *feature = query_param(query, "feature");
|
||||
if (feature != NULL) {
|
||||
int new_state = 0;
|
||||
int reload = 0;
|
||||
|
||||
if (strcmp(feature, "dev_extras") == 0) {
|
||||
if (g_bridge.settings == NULL) {
|
||||
respond_error_json(request, -1, "WebKit settings not available");
|
||||
g_free(feature);
|
||||
return;
|
||||
}
|
||||
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
|
||||
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
|
||||
} else if (strcmp(feature, "file_access") == 0) {
|
||||
if (g_bridge.settings == NULL) {
|
||||
respond_error_json(request, -1, "WebKit settings not available");
|
||||
g_free(feature);
|
||||
return;
|
||||
}
|
||||
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
|
||||
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
|
||||
} else if (strcmp(feature, "universal_access") == 0) {
|
||||
if (g_bridge.settings == NULL) {
|
||||
respond_error_json(request, -1, "WebKit settings not available");
|
||||
g_free(feature);
|
||||
return;
|
||||
}
|
||||
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
|
||||
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
|
||||
} else if (strcmp(feature, "restore_session") == 0) {
|
||||
bs->restore_session = !bs->restore_session;
|
||||
new_state = bs->restore_session;
|
||||
settings_save();
|
||||
} else if (strcmp(feature, "show_tab_close_buttons") == 0) {
|
||||
bs->show_tab_close_buttons = !bs->show_tab_close_buttons;
|
||||
new_state = bs->show_tab_close_buttons;
|
||||
settings_save();
|
||||
tab_manager_apply_settings();
|
||||
} else if (strcmp(feature, "middle_click_close") == 0) {
|
||||
bs->middle_click_close = !bs->middle_click_close;
|
||||
new_state = bs->middle_click_close;
|
||||
settings_save();
|
||||
} else if (strcmp(feature, "ctrl_tab_switch") == 0) {
|
||||
bs->ctrl_tab_switch = !bs->ctrl_tab_switch;
|
||||
new_state = bs->ctrl_tab_switch;
|
||||
settings_save();
|
||||
} else if (strcmp(feature, "tab_drag_reorder") == 0) {
|
||||
bs->tab_drag_reorder = !bs->tab_drag_reorder;
|
||||
new_state = bs->tab_drag_reorder;
|
||||
settings_save();
|
||||
tab_manager_apply_settings();
|
||||
} else if (strcmp(feature, "agent_server_enabled") == 0) {
|
||||
bs->agent_server_enabled = !bs->agent_server_enabled;
|
||||
new_state = bs->agent_server_enabled;
|
||||
settings_save();
|
||||
reload = 1;
|
||||
} else {
|
||||
respond_error_json(request, -1, "Unknown feature");
|
||||
g_free(feature);
|
||||
return;
|
||||
}
|
||||
|
||||
g_print("[settings] %s: %s\n", feature, new_state ? "ON" : "OFF");
|
||||
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "key", feature);
|
||||
cJSON_AddBoolToObject(result, "value", new_state);
|
||||
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
cJSON_Delete(result);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(feature);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Parse feature= from query string. */
|
||||
const char *feat = strstr(query, "feature=");
|
||||
if (feat == NULL) {
|
||||
respond_error_json(request, -1, "Missing feature parameter");
|
||||
/* ── Key/value path: ?key=X&value=Y ───────────────────────────── */
|
||||
char *key = query_param(query, "key");
|
||||
char *value = query_param(query, "value");
|
||||
if (key == NULL || value == NULL) {
|
||||
respond_error_json(request, -1, "Missing key or value parameter");
|
||||
g_free(key);
|
||||
g_free(value);
|
||||
return;
|
||||
}
|
||||
feat += 8; /* skip "feature=" */
|
||||
|
||||
/* Extract feature name (up to & or end). */
|
||||
char feature[64];
|
||||
size_t i = 0;
|
||||
while (feat[i] && feat[i] != '&' && i < sizeof(feature) - 1) {
|
||||
feature[i] = feat[i];
|
||||
i++;
|
||||
}
|
||||
feature[i] = '\0';
|
||||
int reload = 0;
|
||||
|
||||
/* Toggle the setting. */
|
||||
int new_state = 0;
|
||||
if (strcmp(feature, "dev_extras") == 0) {
|
||||
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
|
||||
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
|
||||
} else if (strcmp(feature, "file_access") == 0) {
|
||||
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
|
||||
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
|
||||
} else if (strcmp(feature, "universal_access") == 0) {
|
||||
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
|
||||
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
|
||||
if (strcmp(key, "new_tab_url") == 0) {
|
||||
snprintf(bs->new_tab_url, sizeof(bs->new_tab_url), "%s", value);
|
||||
settings_save();
|
||||
} else if (strcmp(key, "tab_bar_position") == 0) {
|
||||
if (strcasecmp(value, "top") == 0) bs->tab_bar_position = GTK_POS_TOP;
|
||||
else if (strcasecmp(value, "bottom") == 0) bs->tab_bar_position = GTK_POS_BOTTOM;
|
||||
else if (strcasecmp(value, "left") == 0) bs->tab_bar_position = GTK_POS_LEFT;
|
||||
else if (strcasecmp(value, "right") == 0) bs->tab_bar_position = GTK_POS_RIGHT;
|
||||
else {
|
||||
respond_error_json(request, -1, "Invalid position value");
|
||||
g_free(key); g_free(value);
|
||||
return;
|
||||
}
|
||||
settings_save();
|
||||
tab_manager_apply_settings();
|
||||
} else if (strcmp(key, "max_tabs") == 0) {
|
||||
int v = atoi(value);
|
||||
if (v < 1) v = 1;
|
||||
bs->max_tabs = v;
|
||||
settings_save();
|
||||
} else if (strcmp(key, "agent_server_port") == 0) {
|
||||
int v = atoi(value);
|
||||
if (v < 1 || v > 65535) {
|
||||
respond_error_json(request, -1, "Port out of range (1-65535)");
|
||||
g_free(key); g_free(value);
|
||||
return;
|
||||
}
|
||||
bs->agent_server_port = v;
|
||||
settings_save();
|
||||
reload = 1;
|
||||
} else if (strcmp(key, "agent_allowed_origins") == 0) {
|
||||
snprintf(bs->agent_allowed_origins,
|
||||
sizeof(bs->agent_allowed_origins), "%s", value);
|
||||
settings_save();
|
||||
reload = 1;
|
||||
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
|
||||
int v = atoi(value);
|
||||
if (v < 0) v = 0;
|
||||
bs->agent_login_timeout_ms = v;
|
||||
settings_save();
|
||||
} else if (strcmp(key, "bootstrap_relays") == 0) {
|
||||
/* The textarea value uses \n for newlines (URL-encoded as %0A
|
||||
* by the browser, decoded by query_param). Store as-is. */
|
||||
snprintf(bs->bootstrap_relays,
|
||||
sizeof(bs->bootstrap_relays), "%s", value);
|
||||
settings_save();
|
||||
} else {
|
||||
respond_error_json(request, -1, "Unknown feature");
|
||||
respond_error_json(request, -1, "Unknown key");
|
||||
g_free(key); g_free(value);
|
||||
return;
|
||||
}
|
||||
|
||||
g_print("[security] %s: %s\n", feature, new_state ? "ON" : "OFF");
|
||||
g_print("[settings] %s = %s\n", key, value);
|
||||
|
||||
/* Return the new state. */
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "feature", feature);
|
||||
cJSON_AddBoolToObject(result, "enabled", new_state);
|
||||
cJSON_AddStringToObject(result, "key", key);
|
||||
cJSON_AddStringToObject(result, "value", value);
|
||||
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
cJSON_Delete(result);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(key);
|
||||
g_free(value);
|
||||
}
|
||||
|
||||
/* ── Profile page (sovereign://profile) ──────────────────────────── */
|
||||
|
||||
/* Generate the sovereign://profile HTML page.
|
||||
* Reads the user's kind 0 (profile) and kind 3 (contacts) events from
|
||||
* the SQLite database and displays them. This is useful for verifying
|
||||
* that the post-login relay fetch worked correctly. */
|
||||
static void handle_profile_page(WebKitURISchemeRequest *request) {
|
||||
if (g_bridge.pubkey_hex[0] == '\0') {
|
||||
respond_html(request,
|
||||
"<!DOCTYPE html><html><head><meta charset='UTF-8'>"
|
||||
"<style>body{font-family:monospace;max-width:700px;margin:40px auto;"
|
||||
"padding:20px;background:#1a1a1a;color:#e0e0e0;}"
|
||||
"h1{color:#ff4444;border-bottom:2px solid #ff4444;padding-bottom:8px;}"
|
||||
".note{color:#888;}</style></head><body>"
|
||||
"<h1>sovereign browser — Profile</h1>"
|
||||
"<p class='note'>No identity loaded. Log in to see your profile.</p>"
|
||||
"</body></html>");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Fetch kind 0 (profile metadata) and kind 3 (contacts) from SQLite. */
|
||||
cJSON *kind0 = db_get_latest_event(g_bridge.pubkey_hex, 0);
|
||||
cJSON *kind3 = db_get_latest_event(g_bridge.pubkey_hex, 3);
|
||||
|
||||
/* Parse kind 0 content — it's a JSON object with name, about, picture, etc. */
|
||||
const char *name = "", *about = "", *picture = "", *display_name = "";
|
||||
const char *website = "", *lud16 = "";
|
||||
const char *kind0_created = "";
|
||||
cJSON *meta = NULL;
|
||||
if (kind0) {
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
|
||||
if (cJSON_IsString(content) && content->valuestring[0]) {
|
||||
meta = cJSON_Parse(content->valuestring);
|
||||
if (meta) {
|
||||
cJSON *item;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "name");
|
||||
if (cJSON_IsString(item)) name = item->valuestring;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "display_name");
|
||||
if (cJSON_IsString(item)) display_name = item->valuestring;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "about");
|
||||
if (cJSON_IsString(item)) about = item->valuestring;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "picture");
|
||||
if (cJSON_IsString(item)) picture = item->valuestring;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "website");
|
||||
if (cJSON_IsString(item)) website = item->valuestring;
|
||||
item = cJSON_GetObjectItemCaseSensitive(meta, "lud16");
|
||||
if (cJSON_IsString(item)) lud16 = item->valuestring;
|
||||
}
|
||||
}
|
||||
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind0, "created_at");
|
||||
if (cJSON_IsNumber(ca)) {
|
||||
static char ca_buf[32];
|
||||
snprintf(ca_buf, sizeof(ca_buf), "%ld", (long)ca->valuedouble);
|
||||
kind0_created = ca_buf;
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse kind 3 tags — p tags are followed pubkeys. */
|
||||
int contact_count = 0;
|
||||
const char *kind3_created = "";
|
||||
if (kind3) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(kind3, "tags");
|
||||
if (cJSON_IsArray(tags)) {
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "p") == 0) {
|
||||
contact_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind3, "created_at");
|
||||
if (cJSON_IsNumber(ca)) {
|
||||
static char ca3_buf[32];
|
||||
snprintf(ca3_buf, sizeof(ca3_buf), "%ld", (long)ca->valuedouble);
|
||||
kind3_created = ca3_buf;
|
||||
}
|
||||
}
|
||||
|
||||
/* Count events in the database for this pubkey. */
|
||||
int k0_count = db_count_events(g_bridge.pubkey_hex, 0);
|
||||
int k3_count = db_count_events(g_bridge.pubkey_hex, 3);
|
||||
int k10002_count = db_count_events(g_bridge.pubkey_hex, 10002);
|
||||
|
||||
/* Build the npub URI for the QR code. */
|
||||
char npub_uri[160] = "";
|
||||
char *qr_html = g_strdup("");
|
||||
if (g_bridge.pubkey_hex[0]) {
|
||||
unsigned char pubkey_bytes[32] = {0};
|
||||
int valid = 1;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
unsigned int byte;
|
||||
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
|
||||
valid = 0; break;
|
||||
}
|
||||
pubkey_bytes[i] = (unsigned char)byte;
|
||||
}
|
||||
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
|
||||
sizeof(npub_uri)) == 0) {
|
||||
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
|
||||
g_free(qr_html);
|
||||
qr_html = g_strdup_printf(
|
||||
"<div class='qr'><img src='sovereign://qr?text=%s"
|
||||
"&box_size=6&border=4' alt='npub QR code'></div>\n", enc);
|
||||
g_free(enc);
|
||||
}
|
||||
}
|
||||
|
||||
/* Escape strings for HTML. */
|
||||
char *esc_name = g_markup_escape_text(name, -1);
|
||||
char *esc_dname = g_markup_escape_text(display_name, -1);
|
||||
char *esc_about = g_markup_escape_text(about, -1);
|
||||
char *esc_picture = g_markup_escape_text(picture, -1);
|
||||
char *esc_website = g_markup_escape_text(website, -1);
|
||||
char *esc_lud16 = g_markup_escape_text(lud16, -1);
|
||||
char *esc_npub = g_markup_escape_text(npub_uri, -1);
|
||||
|
||||
/* Build optional sub-sections. */
|
||||
char *picture_html = picture[0] ?
|
||||
g_strdup_printf("<img src='%s' alt='Profile picture' "
|
||||
"style='max-width:120px;max-height:120px;border-radius:8px;"
|
||||
"float:right;margin-left:16px;'>", esc_picture) :
|
||||
g_strdup("");
|
||||
|
||||
char *about_html = about[0] ?
|
||||
g_strdup_printf("<div class='info-row'><span>About</span>"
|
||||
"<span class='info' style='max-width:400px;'>%s</span></div>\n",
|
||||
esc_about) :
|
||||
g_strdup("");
|
||||
|
||||
const char *k0_missing = kind0 ? "" :
|
||||
"<p class='missing'>(no kind 0 event in database)</p>\n";
|
||||
const char *k3_missing = kind3 ? "" :
|
||||
"<p class='missing'>(no kind 3 event in database)</p>\n";
|
||||
|
||||
char *html = g_strdup_printf(
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html><head><meta charset='UTF-8'>\n"
|
||||
"<title>sovereign browser — Profile</title>\n"
|
||||
"<style>\n"
|
||||
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
|
||||
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
|
||||
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
|
||||
" h2 { color: #ff8888; margin-top: 30px; }\n"
|
||||
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
|
||||
" .info { color: #888; font-size: 12px; word-break: break-all; }\n"
|
||||
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
|
||||
" .missing { color: #5a2a2a; font-style: italic; }\n"
|
||||
" .qr { text-align: center; margin: 12px 0; }\n"
|
||||
" .qr img { image-rendering: pixelated; border: 4px solid #fff; border-radius: 4px; }\n"
|
||||
"</style>\n"
|
||||
"</head><body>\n"
|
||||
"<h1>sovereign browser — Profile</h1>\n"
|
||||
"%s"
|
||||
"<h2>Identity</h2>\n"
|
||||
" <div class='info-row'><span>Pubkey</span>"
|
||||
"<span class='info'>%.16s…</span></div>\n"
|
||||
" <div class='info-row'><span>npub URI</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Method</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Signing</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
"%s"
|
||||
"\n"
|
||||
"<h2>Profile (kind 0)</h2>\n"
|
||||
"%s"
|
||||
" <div class='info-row'><span>Name</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Display Name</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
"%s"
|
||||
" <div class='info-row'><span>Website</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Lightning (lud16)</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Last updated</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Events in DB</span>"
|
||||
"<span class='info'>%d</span></div>\n"
|
||||
"\n"
|
||||
"<h2>Contacts (kind 3)</h2>\n"
|
||||
"%s"
|
||||
" <div class='info-row'><span>Following</span>"
|
||||
"<span class='info'>%d pubkey(s)</span></div>\n"
|
||||
" <div class='info-row'><span>Last updated</span>"
|
||||
"<span class='info'>%s</span></div>\n"
|
||||
" <div class='info-row'><span>Events in DB</span>"
|
||||
"<span class='info'>%d</span></div>\n"
|
||||
"\n"
|
||||
"<h2>Relay List (kind 10002)</h2>\n"
|
||||
" <div class='info-row'><span>Events in DB</span>"
|
||||
"<span class='info'>%d</span></div>\n"
|
||||
"\n"
|
||||
"<p class='note'>This page shows data cached in the local SQLite database "
|
||||
"from the bootstrap relay fetch. If fields show <span class='missing'>"
|
||||
"(not found)</span>, the relay fetch hasn't completed or the user has no "
|
||||
"events of that kind on the bootstrap relays.</p>\n"
|
||||
"</body></html>\n",
|
||||
picture_html,
|
||||
g_bridge.pubkey_hex,
|
||||
esc_npub,
|
||||
g_bridge.readonly ? "read-only" : "signing",
|
||||
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
|
||||
qr_html,
|
||||
k0_missing,
|
||||
esc_name,
|
||||
esc_dname,
|
||||
about_html,
|
||||
esc_website,
|
||||
esc_lud16,
|
||||
kind0_created[0] ? kind0_created : "(not found)",
|
||||
k0_count,
|
||||
k3_missing,
|
||||
contact_count,
|
||||
kind3_created[0] ? kind3_created : "(not found)",
|
||||
k3_count,
|
||||
k10002_count
|
||||
);
|
||||
|
||||
respond_html(request, html);
|
||||
g_free(html);
|
||||
g_free(qr_html);
|
||||
g_free(esc_name);
|
||||
g_free(esc_dname);
|
||||
g_free(esc_about);
|
||||
g_free(esc_picture);
|
||||
g_free(esc_website);
|
||||
g_free(esc_lud16);
|
||||
g_free(esc_npub);
|
||||
g_free(picture_html);
|
||||
g_free(about_html);
|
||||
if (meta) cJSON_Delete(meta);
|
||||
if (kind0) cJSON_Delete(kind0);
|
||||
if (kind3) cJSON_Delete(kind3);
|
||||
}
|
||||
|
||||
/* ── URI scheme callback ────────────────────────────────────────── */
|
||||
@@ -528,6 +1112,49 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://qr?text=... — render a QR code as PNG.
|
||||
* Optional query params:
|
||||
* text (required) — the string to encode (URL-decoded).
|
||||
* box_size (optional) — pixels per module (default 8).
|
||||
* border (optional) — quiet-zone modules (default 4). */
|
||||
if (strcmp(uri, "sovereign://qr") == 0 ||
|
||||
strncmp(uri, "sovereign://qr?", 15) == 0) {
|
||||
const char *q = strchr(uri, '?');
|
||||
char *text = query_param(q ? q + 1 : "", "text");
|
||||
if (text == NULL || text[0] == '\0') {
|
||||
respond_error_json(request, -1, "Missing 'text' parameter");
|
||||
g_free(text);
|
||||
return;
|
||||
}
|
||||
char *bs_str = query_param(q ? q + 1 : "", "box_size");
|
||||
char *bd_str = query_param(q ? q + 1 : "", "border");
|
||||
int box_size = bs_str ? atoi(bs_str) : 8;
|
||||
int border = bd_str ? atoi(bd_str) : 4;
|
||||
if (box_size < 1) box_size = 1;
|
||||
if (box_size > 32) box_size = 32;
|
||||
if (border < 0) border = 0;
|
||||
if (border > 16) border = 16;
|
||||
|
||||
GBytes *png = qr_render_png(text, box_size, border);
|
||||
if (png == NULL) {
|
||||
respond_error_json(request, -1,
|
||||
"Failed to encode QR code (text too long?)");
|
||||
} else {
|
||||
respond_bytes(request, png, "image/png");
|
||||
}
|
||||
g_free(text);
|
||||
g_free(bs_str);
|
||||
g_free(bd_str);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://profile — show the user's Nostr profile from
|
||||
* cached kind 0/3 events in the SQLite database. */
|
||||
if (strcmp(uri, "sovereign://profile") == 0) {
|
||||
handle_profile_page(request);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://settings requests. */
|
||||
if (strcmp(uri, "sovereign://settings") == 0 ||
|
||||
strncmp(uri, "sovereign://settings?", 21) == 0) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* qr.c — QR code rendering wrapper around libqrencode
|
||||
*
|
||||
* Encodes text via QRcode_encodeString() and renders the module matrix
|
||||
* into an RGBA pixel buffer, then wraps it in a GdkPixbuf. The pixbuf
|
||||
* can be saved to PNG in memory (qr_render_png) or used directly by GTK
|
||||
* widgets (qr_render_pixbuf).
|
||||
*
|
||||
* The QRcode->data[] layout is row-major, one byte per module, with the
|
||||
* least-significant bit indicating a dark module (1 = dark, 0 = light).
|
||||
* See <qrencode.h> for details.
|
||||
*/
|
||||
|
||||
#include "qr.h"
|
||||
|
||||
#include <qrencode.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Internal: render QRcode modules into a GdkPixbuf ──────────────── */
|
||||
|
||||
static GdkPixbuf *qr_code_to_pixbuf(const QRcode *code, int box_size,
|
||||
int border) {
|
||||
if (code == NULL || code->data == NULL || code->width <= 0) {
|
||||
return NULL;
|
||||
}
|
||||
if (box_size < 1) box_size = 1;
|
||||
if (border < 0) border = 0;
|
||||
|
||||
int modules = code->width; /* width == height for QR codes */
|
||||
int dim = (modules + 2 * border) * box_size;
|
||||
|
||||
/* Allocate an RGBA buffer (gdk-pixbuf uses 8 bits/channel, 4 channels
|
||||
* when an alpha channel is present; we use RGB to keep it simple and
|
||||
* smaller). */
|
||||
GdkPixbuf *pb = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE /* no alpha */,
|
||||
8, dim, dim);
|
||||
if (pb == NULL) return NULL;
|
||||
|
||||
int rowstride = gdk_pixbuf_get_rowstride(pb);
|
||||
int n_channels = gdk_pixbuf_get_n_channels(pb);
|
||||
guchar *pixels = gdk_pixbuf_get_pixels(pb);
|
||||
|
||||
/* Fill with white (the quiet zone + light modules). */
|
||||
for (int y = 0; y < dim; y++) {
|
||||
guchar *row = pixels + y * rowstride;
|
||||
for (int x = 0; x < dim; x++) {
|
||||
row[x * n_channels + 0] = 255; /* R */
|
||||
row[x * n_channels + 1] = 255; /* G */
|
||||
row[x * n_channels + 2] = 255; /* B */
|
||||
}
|
||||
}
|
||||
|
||||
/* Paint dark modules. QRcode->data is row-major: data[y * width + x],
|
||||
* and bit 0 (LSB) of each byte is the module color (1 = dark). */
|
||||
for (int my = 0; my < modules; my++) {
|
||||
for (int mx = 0; mx < modules; mx++) {
|
||||
unsigned char b = code->data[my * code->width + mx];
|
||||
if (b & 1) {
|
||||
/* Dark module — paint the box_size×box_size block. */
|
||||
int px0 = (mx + border) * box_size;
|
||||
int py0 = (my + border) * box_size;
|
||||
for (int dy = 0; dy < box_size; dy++) {
|
||||
guchar *row = pixels + (py0 + dy) * rowstride;
|
||||
for (int dx = 0; dx < box_size; dx++) {
|
||||
int idx = (px0 + dx) * n_channels;
|
||||
row[idx + 0] = 0; /* R */
|
||||
row[idx + 1] = 0; /* G */
|
||||
row[idx + 2] = 0; /* B */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pb;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border) {
|
||||
if (text == NULL || text[0] == '\0') return NULL;
|
||||
|
||||
/* version=0 → auto-select the smallest version that fits.
|
||||
* level=QR_ECLEVEL_M → medium error correction (good default for
|
||||
* identity/URI codes that might be scanned at an angle or off-screen).
|
||||
* hint=QR_MODE_8 → treat input as 8-bit data (handles URIs, npubs, etc.).
|
||||
* casesensitive=1 → don't fold to uppercase. */
|
||||
QRcode *code = QRcode_encodeString(text, 0, QR_ECLEVEL_M,
|
||||
QR_MODE_8, 1);
|
||||
if (code == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GdkPixbuf *pb = qr_code_to_pixbuf(code, box_size, border);
|
||||
QRcode_free(code);
|
||||
return pb;
|
||||
}
|
||||
|
||||
GBytes *qr_render_png(const char *text, int box_size, int border) {
|
||||
GdkPixbuf *pb = qr_render_pixbuf(text, box_size, border);
|
||||
if (pb == NULL) return NULL;
|
||||
|
||||
gchar *buffer = NULL;
|
||||
gsize size = 0;
|
||||
GError *err = NULL;
|
||||
|
||||
if (!gdk_pixbuf_save_to_bufferv(pb, &buffer, &size, "png",
|
||||
NULL, NULL, &err)) {
|
||||
g_printerr("[qr] Failed to encode PNG: %s\n",
|
||||
err ? err->message : "(unknown)");
|
||||
if (err) g_error_free(err);
|
||||
g_object_unref(pb);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_object_unref(pb);
|
||||
|
||||
/* g_bytes_new_take takes ownership of buffer (g_free). */
|
||||
GBytes *bytes = g_bytes_new_take(buffer, size);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* qr.h — QR code rendering wrapper around libqrencode
|
||||
*
|
||||
* Provides a stable API so the rest of the codebase doesn't include
|
||||
* <qrencode.h> directly. Renders a QR code for a given text string
|
||||
* into either an in-memory PNG (GBytes) or a GdkPixbuf (for GTK widgets).
|
||||
*
|
||||
* Dependencies: libqrencode (LGPL-2.1+), gdk-pixbuf (via GTK).
|
||||
*/
|
||||
|
||||
#ifndef QR_H
|
||||
#define QR_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <gdk-pixbuf/gdk-pixbuf.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Render text as a QR code and return the PNG image in memory.
|
||||
*
|
||||
* text : NUL-terminated string to encode (UTF-8).
|
||||
* box_size : pixels per QR module (e.g. 8 for a chunky, scannable code).
|
||||
* border : number of quiet-zone modules around the code (4 is standard).
|
||||
*
|
||||
* Returns a newly allocated GBytes containing PNG data, or NULL on failure.
|
||||
* Caller must unref with g_bytes_unref().
|
||||
*/
|
||||
GBytes *qr_render_png(const char *text, int box_size, int border);
|
||||
|
||||
/*
|
||||
* Render text as a QR code and return a GdkPixbuf.
|
||||
*
|
||||
* text : NUL-terminated string to encode (UTF-8).
|
||||
* box_size : pixels per QR module.
|
||||
* border : number of quiet-zone modules around the code.
|
||||
*
|
||||
* Returns a newly allocated GdkPixbuf, or NULL on failure.
|
||||
* Caller must unref with g_object_unref().
|
||||
*/
|
||||
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* QR_H */
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* relay_fetch.c — post-login bootstrap relay fetch for sovereign_browser
|
||||
*
|
||||
* Uses synchronous_query_relays_with_progress() from nostr_core_lib to
|
||||
* query the user's kind 0/3/10002 events from the bootstrap relays, then
|
||||
* stores them in the SQLite database via db_store_event().
|
||||
*
|
||||
* This runs in a background thread (relay_fetch_thread) so the browser
|
||||
* is usable immediately after login. SQLite is opened with
|
||||
* SQLITE_OPEN_FULLMUTEX so concurrent access from the main thread is safe.
|
||||
*/
|
||||
|
||||
#include "relay_fetch.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <glib.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* Maximum number of relays to parse from the bootstrap_relays setting. */
|
||||
#define MAX_RELAYS 32
|
||||
|
||||
/* Relay query timeout in seconds. */
|
||||
#define RELAY_TIMEOUT_SECONDS 15
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
const char **relay_urls,
|
||||
int relay_count) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[relay] No pubkey, skipping bootstrap fetch\n");
|
||||
return -1;
|
||||
}
|
||||
if (relay_urls == NULL || relay_count <= 0) {
|
||||
g_printerr("[relay] No bootstrap relays configured\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[relay] Fetching kind 0/3/10002 for %s from %d relay(s)...\n",
|
||||
pubkey_hex, relay_count);
|
||||
|
||||
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002]} */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
|
||||
* from all relays. No NIP-42 auth needed for public events. */
|
||||
int result_count = 0;
|
||||
cJSON **results = synchronous_query_relays_with_progress(
|
||||
relay_urls, relay_count, filter,
|
||||
RELAY_QUERY_ALL_RESULTS, &result_count,
|
||||
RELAY_TIMEOUT_SECONDS,
|
||||
NULL, NULL, /* no progress callback */
|
||||
0, NULL /* no NIP-42 auth */
|
||||
);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (results == NULL || result_count <= 0) {
|
||||
g_print("[relay] No events found (relays may be unreachable or "
|
||||
"the user has no kind 0/3/10002 events)\n");
|
||||
if (results) free(results);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Store each event in the database. */
|
||||
int stored = 0;
|
||||
for (int i = 0; i < result_count; i++) {
|
||||
if (results[i] == NULL) continue;
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
} else {
|
||||
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
|
||||
result_count);
|
||||
}
|
||||
cJSON_Delete(results[i]);
|
||||
}
|
||||
free(results);
|
||||
|
||||
g_print("[relay] Fetched %d event(s), stored %d in database\n",
|
||||
result_count, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* ── Background thread ─────────────────────────────────────────────── */
|
||||
|
||||
gpointer relay_fetch_thread(gpointer data) {
|
||||
char *pubkey_hex = (char *)data;
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[relay] Thread started with no pubkey\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Parse bootstrap relays from settings. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
char *relay_buf = g_strdup(s->bootstrap_relays);
|
||||
const char *relay_urls[MAX_RELAYS];
|
||||
int relay_count = 0;
|
||||
|
||||
/* Tokenize the buffer in place. */
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && relay_count < MAX_RELAYS) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
relay_urls[relay_count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
|
||||
if (relay_count == 0) {
|
||||
g_print("[relay] No valid bootstrap relay URLs in settings\n");
|
||||
g_free(relay_buf);
|
||||
g_free(pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
|
||||
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
|
||||
|
||||
g_free(relay_buf);
|
||||
g_free(pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* relay_fetch.h — post-login bootstrap relay fetch for sovereign_browser
|
||||
*
|
||||
* After the user logs in, fetches their kind 0 (profile), kind 3 (contacts),
|
||||
* and kind 10002 (relay list) events from the configured bootstrap relays
|
||||
* and stores them in the SQLite database.
|
||||
*/
|
||||
|
||||
#ifndef RELAY_FETCH_H
|
||||
#define RELAY_FETCH_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays
|
||||
* and store them in the SQLite database via db_store_event().
|
||||
*
|
||||
* pubkey_hex — user's hex pubkey (64 chars, no 0x prefix)
|
||||
* relay_urls — array of relay URL strings (e.g. "wss://relay.damus.io")
|
||||
* relay_count — number of relays in the array
|
||||
*
|
||||
* Returns the number of events fetched and stored, or -1 on error.
|
||||
*/
|
||||
int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
|
||||
/*
|
||||
* Background thread entry point for relay_fetch_bootstrap.
|
||||
* Pass a pointer to a g_strdup'd pubkey_hex string (the thread frees it).
|
||||
* Reads bootstrap relays from the global settings singleton.
|
||||
*
|
||||
* Usage: g_thread_new("relay-fetch", relay_fetch_thread, g_strdup(pubkey));
|
||||
*/
|
||||
gpointer relay_fetch_thread(gpointer data);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RELAY_FETCH_H */
|
||||
@@ -37,6 +37,8 @@ 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;
|
||||
snprintf(s->bootstrap_relays, sizeof(s->bootstrap_relays), "%s",
|
||||
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
|
||||
}
|
||||
|
||||
/* ── Path helper ──────────────────────────────────────────────────── */
|
||||
@@ -168,6 +170,22 @@ void settings_load(void) {
|
||||
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
|
||||
g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms);
|
||||
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
|
||||
} else if (strcmp(key, "bootstrap_relays") == 0) {
|
||||
/* The value may contain escaped newlines (\n) since the file
|
||||
* is line-based key=value. Convert \n to actual newlines. */
|
||||
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX];
|
||||
size_t ri = 0, wi = 0;
|
||||
while (value[ri] && wi < sizeof(buf) - 1) {
|
||||
if (value[ri] == '\\' && value[ri + 1] == 'n') {
|
||||
buf[wi++] = '\n';
|
||||
ri += 2;
|
||||
} else {
|
||||
buf[wi++] = value[ri++];
|
||||
}
|
||||
}
|
||||
buf[wi] = '\0';
|
||||
snprintf(g_settings.bootstrap_relays,
|
||||
sizeof(g_settings.bootstrap_relays), "%s", buf);
|
||||
}
|
||||
/* Unknown keys are silently ignored. */
|
||||
}
|
||||
@@ -208,6 +226,25 @@ void settings_save(void) {
|
||||
fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins);
|
||||
fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms);
|
||||
|
||||
/* Save bootstrap relays with newlines escaped as \n (the file is
|
||||
* line-based key=value, so actual newlines would break parsing). */
|
||||
{
|
||||
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX * 2];
|
||||
size_t ri = 0, wi = 0;
|
||||
while (g_settings.bootstrap_relays[ri] &&
|
||||
wi < sizeof(buf) - 1) {
|
||||
if (g_settings.bootstrap_relays[ri] == '\n') {
|
||||
buf[wi++] = '\\';
|
||||
buf[wi++] = 'n';
|
||||
ri++;
|
||||
} else {
|
||||
buf[wi++] = g_settings.bootstrap_relays[ri++];
|
||||
}
|
||||
}
|
||||
buf[wi] = '\0';
|
||||
fprintf(f, "bootstrap_relays=%s\n", buf);
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ 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"
|
||||
|
||||
typedef struct {
|
||||
gboolean restore_session; /* restore open tabs on next launch */
|
||||
@@ -35,6 +40,7 @@ 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 */
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
|
||||
+32
-6
@@ -60,6 +60,10 @@ static char *normalize_url(const char *input) {
|
||||
if (strstr(input, "://") != NULL) {
|
||||
return g_strdup(input);
|
||||
}
|
||||
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
|
||||
if (strncmp(input, "about:", 6) == 0) {
|
||||
return g_strdup(input);
|
||||
}
|
||||
return g_strdup_printf("https://%s", input);
|
||||
}
|
||||
|
||||
@@ -328,7 +332,14 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
if (load_event == WEBKIT_LOAD_COMMITTED) {
|
||||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||||
if (uri != NULL) {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
|
||||
/* Show an empty URL bar for blank pages so the user can
|
||||
* immediately type a URL. Keep current_url as the real URI
|
||||
* (about:blank) so session save/duplicate still work. */
|
||||
if (strcmp(uri, "about:blank") == 0) {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
|
||||
} else {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
|
||||
}
|
||||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
|
||||
|
||||
/* Set a temporary title from the URL host while the page loads.
|
||||
@@ -706,7 +717,13 @@ static tab_info_t *tab_create(const char *url) {
|
||||
gtk_box_pack_start(GTK_BOX(toolbar), refresh_btn, FALSE, FALSE, 0);
|
||||
|
||||
tab->url_entry = gtk_entry_new();
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
|
||||
/* Show an empty URL bar for new-tab pages (about:blank) so the user
|
||||
* can immediately type a URL. For real URLs, show the URL. */
|
||||
if (default_url && strstr(default_url, "about:blank") != NULL) {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
|
||||
} else {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
|
||||
}
|
||||
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
|
||||
|
||||
/* Ensure the webview expands vertically to fill the available space.
|
||||
@@ -815,13 +832,22 @@ int tab_manager_new_tab(const char *url) {
|
||||
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook), tab->page,
|
||||
s->tab_drag_reorder);
|
||||
|
||||
/* Switch to the new tab. */
|
||||
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), page_num);
|
||||
|
||||
/* Show everything. */
|
||||
/* Show the tab widgets before switching to it — the page must be
|
||||
* realized/mapped before it can become the current page and receive
|
||||
* focus. */
|
||||
gtk_widget_show_all(tab->page);
|
||||
gtk_widget_show_all(tab->tab_label);
|
||||
|
||||
/* Switch to the new tab (now that it's visible). */
|
||||
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), page_num);
|
||||
|
||||
/* Give the URL entry keyboard focus so the user can immediately
|
||||
* type a URL. Select all text so typing replaces the current URL.
|
||||
* Use gtk_widget_grab_focus() on the entry to ensure it receives
|
||||
* keyboard input. */
|
||||
gtk_widget_grab_focus(tab->url_entry);
|
||||
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
|
||||
|
||||
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
|
||||
return page_num;
|
||||
}
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.12"
|
||||
#define SB_VERSION "v0.0.13"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 12
|
||||
#define SB_VERSION_PATCH 13
|
||||
|
||||
#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))
|
||||
Reference in New Issue
Block a user