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:
Laan Tungir
2026-07-12 11:01:36 -04:00
parent 372339aa35
commit 8c75c74c38
27 changed files with 3531 additions and 234 deletions
+72
View File
@@ -0,0 +1,72 @@
# Agent Instructions — sovereign_browser
## Browser Management
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never launch `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open.
```bash
./browser.sh start # build + launch (detached, waits for MCP ready)
./browser.sh stop # kill running instance
./browser.sh restart # stop + build + start
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
```
The browser's MCP server runs on `http://localhost:17777/mcp` (Streamable HTTP transport).
## Login
The browser requires Nostr login before any browser tools work. The quickest way to log in is with a random key:
```bash
# Via MCP (curl):
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"random"}}}'
```
Or use the `login` MCP tool with `{"method": "random"}`. This generates a fresh random Nostr keypair and logs in immediately. The response includes the `nsec` so you can save it if needed.
Other login methods: `local` (nsec/hex), `seed` (mnemonic), `readonly` (npub), `nip46` (bunker URL), `nsigner` (hardware signer).
## MCP Server
The browser exposes 100 MCP tools for browser automation:
- **Login tools**: `login_status`, `login`, `logout`, `switch_identity`
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`, `eval`, `screenshot`, `screenshot_annotated`
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`, `hover`, `focus`, `select`, `check`, `uncheck`, `drag`, `keyboard_type`, `insert_text`, `keydown`, `keyup`, `scroll_into_view`
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`, `find_alt`, `find_title`, `find_testid`, `find_first`, `find_last`, `find_nth`
- **Get info**: `get_value`, `get_count`, `get_box`, `get_styles`
- **Check state**: `is_visible`, `is_enabled`, `is_checked`
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`, `wait_for_fn`
- **Batch**: `batch` (execute multiple tool calls in sequence)
- **Cookies & storage**: `cookies_get`, `cookies_set`, `cookies_clear`, `storage_local_*`, `storage_session_*`
- **Mouse**: `mouse_move`, `mouse_down`, `mouse_up`, `mouse_wheel`
- **Clipboard**: `clipboard_read`, `clipboard_write`, `clipboard_copy`, `clipboard_paste`
- **Settings**: `set_viewport`, `set_media`, `set_offline`, `set_headers`, `set_credentials`
- **Frames**: `frame_switch`, `frame_main`
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
- **Debug**: `console`, `errors`, `highlight`
- **State**: `state_save`, `state_load`
- **Files**: `upload`, `pdf`
## Common Workflow
1. `./browser.sh start` (or `restart` if already running)
2. Login: call `login` tool with `{"method": "random"}`
3. Navigate: call `open` tool with a URL
4. Snapshot: call `snapshot` to get the accessibility tree with element refs
5. Interact: use refs (e.g. `@e1`) with `click`, `fill`, etc.
6. Screenshot: call `screenshot` to see the page visually
## Building
```bash
make # build the browser
make clean # clean build artifacts
```
The browser is a WebKitGTK + C99 application. It links against `webkit2gtk-4.1`, `libsoup-3.0`, and `nostr_core_lib` (vendored).
+50
View File
@@ -0,0 +1,50 @@
# sovereign_browser — Agent Rules
## Browser Management (CRITICAL)
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never run `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open, which blocks the command runner.
```bash
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
./browser.sh stop # kill running instance
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
```
## Login
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
```bash
# Generate a fresh key and log in immediately — no credentials needed
./browser.sh start --login-method generate
# Generate a key and open a URL in one command
./browser.sh start --login-method generate --url https://example.com
# Log in with a local key (nsec)
./browser.sh start --login-method local --nsec nsec1...
# Read-only mode (npub, no signing)
./browser.sh start --login-method readonly --npub npub1...
```
Other login methods: `seed` (BIP-39 mnemonic), `nip46` (bunker:// URL), `nsigner` (hardware signer). Run `./sovereign_browser --help` for the full flag list.
Alternatively, you can still log in at runtime via the MCP `login` tool with `{"method": "generate"}` (or `local`, `seed`, `readonly`, `nip46`, `nsigner`).
## MCP Server
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
- See `.roo/agents.md` for the full tool list and workflow
## Building
```bash
make # build
make clean # clean
```
WebKitGTK + C99. Links `webkit2gtk-4.1`, `libsoup-3.0`, `nostr_core_lib` (vendored).
+3 -3
View File
@@ -6,18 +6,18 @@
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 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)
+78
View File
@@ -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)
```
+1 -1
View File
@@ -1 +1 @@
0.0.12
0.0.13
Executable
+210
View File
@@ -0,0 +1,210 @@
#!/bin/bash
#
# browser.sh — safe start/stop/restart for sovereign_browser
#
# Agents (Roo Code, etc.) should ALWAYS use this script to manage the browser
# process. It fully detaches the GUI from the terminal so the agent's command
# runner doesn't hang waiting for output that never ends.
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill any running instance
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./browser.sh status — check if running + MCP responsive
# ./browser.sh log — tail the browser log
#
# Extra args after start/restart are forwarded to the binary, e.g.:
# ./browser.sh start --login-method generate --url https://example.com
# ./browser.sh restart --no-agent --url https://a.com --url https://b.com
#
# The browser's stdout/stderr go to /tmp/sovereign_browser.log
# The PID is stored in /tmp/sovereign_browser.pid
set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
PORT=17777
# Check if a PID is actually the sovereign_browser process.
# This safety guard prevents us from accidentally killing unrelated
# processes (e.g. Roo Code / VS Code extension host, which connects
# to the MCP server as a client and would otherwise be caught by
# a naive `lsof -ti :PORT` lookup).
is_browser_pid() {
local pid="$1"
[[ -z "$pid" ]] && return 1
kill -0 "$pid" 2>/dev/null || return 1
# /proc/<pid>/comm contains the executable name (truncated to 15 chars).
# For sovereign_browser it's "sovereign_brows" (15-char limit).
local comm
comm=$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]')
[[ "$comm" == "sovereign_browser" || "$comm" == "sovereign_brows" ]]
}
get_pid() {
# Primary: use the PID file (written by cmd_start).
if [[ -f "$PID_FILE" ]]; then
local pid
pid=$(cat "$PID_FILE" 2>/dev/null | head -n1 || echo "")
if [[ -n "$pid" ]] && is_browser_pid "$pid"; then
echo "$pid"
return 0
fi
fi
# Fallback: find the process LISTENing on the port.
# -sTCP:LISTEN matches only the listening socket, never client
# connections (e.g. Roo Code's MCP client). This is critical —
# without it, `lsof -ti :PORT` would also return Roo Code's PID
# and we'd kill the agent driving the browser.
local port_pid
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
echo "$port_pid"
return 0
fi
return 1
}
cmd_stop() {
local stopped_any=0
# Collect candidate PIDs from the PID file and from LISTENing sockets.
# We use -sTCP:LISTEN so we only match the server, never client
# connections (Roo Code / VS Code ext host connect to the MCP server
# and would be killed by a bare `lsof -ti :PORT`).
local candidates=()
if [[ -f "$PID_FILE" ]]; then
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < "$PID_FILE" 2>/dev/null
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
# Deduplicate and kill only verified sovereign_browser PIDs.
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
if is_browser_pid "$pid"; then
echo "[browser] Stopping PID $pid..."
kill "$pid" 2>/dev/null || true
stopped_any=1
fi
done
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
if is_browser_pid "$pid"; then
echo "[browser] Force killing PID $pid..."
kill -9 "$pid" 2>/dev/null || true
fi
done
sleep 1
fi
rm -f "$PID_FILE"
# Final check: is anything still LISTENing on the port?
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "[browser] Warning: process still listening on port $PORT."
else
echo "[browser] Stopped."
fi
return 0
}
cmd_start() {
local extra_args=("$@")
# Don't start if already running
if get_pid >/dev/null 2>&1; then
echo "[browser] Already running (PID $(get_pid)). Use 'restart' to reload."
return 0
fi
# Build first
echo "[browser] Building..."
if ! make -s 2>/dev/null; then
echo "[browser] Build failed!" >&2
return 1
fi
echo "[browser] Build OK."
# Launch fully detached:
# setsid — new session, detached from controlling terminal
# nohup — immune to SIGHUP
# < /dev/null — detach stdin (GUI processes sometimes block on this)
# > $LOG 2>&1 — redirect all output to log file
# & disown — background + remove from job table
echo "[browser] Launching (detached)..."
if [[ ${#extra_args[@]} -gt 0 ]]; then
echo "[browser] Forwarding args: ${extra_args[*]}"
fi
setsid nohup "$BIN" "${extra_args[@]}" < /dev/null > "$LOG" 2>&1 &
local pid=$!
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# Wait for the MCP server to be ready (up to 10 seconds)
echo "[browser] Waiting for MCP server on port $PORT..."
for i in $(seq 1 100); do
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server is ready (PID $pid)."
return 0
fi
sleep 0.1
done
echo "[browser] Warning: MCP server didn't respond within 10s." >&2
echo "[browser] Check log: $LOG" >&2
return 1
}
cmd_restart() {
cmd_stop
sleep 1
cmd_start "$@"
}
cmd_status() {
local pid
if pid=$(get_pid 2>/dev/null); then
echo "[browser] Running (PID $pid)."
# Check MCP
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server: responsive"
else
echo "[browser] MCP server: NOT responding"
fi
else
echo "[browser] Not running."
fi
}
cmd_log() {
if [[ -f "$LOG" ]]; then
tail -50 "$LOG"
else
echo "[browser] No log file at $LOG"
fi
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
*)
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
echo " start/restart forward extra args to the binary." >&2
exit 1
;;
esac
+264
View File
@@ -0,0 +1,264 @@
# CLI Flags Plan — sovereign_browser
## Rename: "random" → "generate"
As part of this work, rename the login method string `"random"` to
`"generate"` everywhere it appears. "random" implies an arbitrary method
choice; "generate" accurately describes what happens — a fresh local key
is generated and used for login.
Affected locations (from `grep`):
| File | Line | Change |
|---|---|---|
| [`src/agent_login.c`](src/agent_login.c:184) | 184 | `make_login_data("random", ...)``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:425) | 425 | error string listing methods |
| [`src/agent_login.c`](src/agent_login.c:440) | 440 | `strcmp(method, "random")``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:451) | 451 | error string listing methods |
| [`src/agent_mcp.c`](src/agent_mcp.c:137) | 137 | `login` tool description + enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:138) | 138 | `login` tool JSON schema enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:145) | 145 | `switch_identity` tool description |
| [`src/agent_mcp.c`](src/agent_mcp.c:146) | 146 | `switch_identity` tool JSON schema enum |
| [`tests/test_agent_login.py`](tests/test_agent_login.py) | — | any test using `method: "random"` |
| [`README.md`](README.md) | — | any documentation referencing "random" |
| `.roorules` | — | the `login` example uses `"method":"random"` |
The internal C function `login_random()` in
[`src/agent_login.c`](src/agent_login.c) can keep its name (internal) or
be renamed to `login_generate()` for consistency — recommend renaming
for clarity.
## Goal
Add command-line flags to `sovereign_browser` so that startup URLs, agent
server settings, session behavior, and — critically — Nostr login can be
controlled without the GTK login dialog. This enables headless/automated
use (agents, CI, scripting) while preserving the existing interactive
flow as the default.
## Current State
- [`main()`](src/main.c:493) reads only `argv[1]` as an optional start URL
([`src/main.c:502`](src/main.c:502)). No `getopt`, no `--help`.
- [`gtk_init(&argc, &argv)`](src/main.c:496) runs first and would consume
GTK's own flags; our parser must run **before** `gtk_init` so we can
strip our flags out of `argv` before GTK sees them (otherwise GTK aborts
on unknown options like `--login-method`).
- Login methods are enumerated in
[`key_store_method_t`](src/key_store.h:19) and exercised by
[`agent_login()`](src/agent_login.h:38) which already accepts a cJSON
params object. The CLI login path can reuse `agent_login()` directly —
it calls `app_set_signer()` and sets the same global state the GTK
dialog would.
- Settings live in [`browser_settings_t`](src/settings.h:25) and are
loaded from `~/.sovereign_browser/settings.conf`. CLI flags should
**override** settings, not replace them.
## Design Principles
1. **Reuse `agent_login()`** for CLI login — it already does everything
the GTK dialog does, returns structured results, and sets global state
via `app_set_signer()`. The CLI parser just builds the cJSON params
object from flags and calls it.
2. **Parse before `gtk_init()`** so GTK doesn't choke on our flags. We
strip recognized flags from `argc/argv` and pass the reduced vector to
`gtk_init()`.
3. **Flags override settings.conf** but do not write to it. A flag is a
one-shot override for this invocation.
4. **Login flags are mutually exclusive at the method level** — specify
one `--login-*` method; method-specific args are validated against it.
5. **`--login-method generate` is the zero-config path** — generates a
fresh key, logs in, and skips the dialog. Ideal for agents/CI.
6. **Backward compatible**`./sovereign_browser https://example.com`
still works as today (positional URL).
7. **Use `getopt_long`** (POSIX, available on Linux, C99-compatible).
No external deps.
## Proposed Flag Set
### Browser / Startup
| Flag | Arg | Description |
|---|---|---|
| `--url <url>` (repeatable) | URL | Open one or more URLs in tabs at startup. Positional URLs (existing behavior) are still accepted and appended after any `--url` flags. |
| `--new-tab-url <url>` | URL | Override `settings.new_tab_url` for this run (used by Ctrl+T / new-tab button). |
| `--no-session-restore` | — | Skip [`session_restore()`](src/session.c) even if `settings.restore_session` is true. |
| `--session-restore` | — | Force session restore even if disabled in settings. |
| `--max-tabs <n>` | int | Override `settings.max_tabs` for this run. |
| `--version`, `-V` | — | Print `SB_VERSION` (from [`src/version.h`](src/version.h)) and exit 0. |
| `--help`, `-h` | — | Print usage and exit 0. |
### Agent Server
| Flag | Arg | Description |
|---|---|---|
| `--port <port>` | int | Override `settings.agent_server_port` (default 17777). |
| `--no-agent` | — | Disable the agent MCP server for this run (overrides `settings.agent_server_enabled`). |
| `--agent` | — | Force-enable the agent server even if disabled in settings. |
| `--agent-origin <origin>` (repeatable) | string | Append to `agent_allowed_origins` for this run. |
### Login (mutually exclusive method flags)
Exactly one of these may be specified. If none is specified, the GTK
login dialog runs as today.
| Flag | Arg | Description |
|---|---|---|
| `--login-method <m>` | `generate\|local\|seed\|readonly\|nip46\|nsigner` | Select login method. Required to use any other `--login-*` flag. `generate` needs no further flags. |
| `--nsec <nsec1...>` | string | (local) nsec bech32 private key. |
| `--privkey <hex>` | string | (local) 64-char hex private key. Alternative to `--nsec`. |
| `--mnemonic <words>` | string | (seed) BIP-39 mnemonic, quoted ("word1 word2 ..."). |
| `--account <n>` | int | (seed) BIP-44 account index, default 0. |
| `--npub <npub1...>` | string | (readonly) npub bech32 public key. |
| `--pubkey <hex>` | string | (readonly) 64-char hex pubkey. Alternative to `--npub`. |
| `--bunker <url>` | URL | (nip46) `bunker://...` remote signer URL. |
| `--nsigner-transport <t>` | `serial\|unix\|tcp\|qrexec` | (nsigner) transport type. |
| `--nsigner-device <path>` | string | (nsigner) device path / socket / host:port / qube. |
| `--nsigner-service <name>` | string | (nsigner) qrexec service name (qrexec transport only). |
| `--nsigner-index <n>` | int | (nsigner) NIP-06 nostr_index, default 0. |
| `--no-save-identity` | — | Do not persist the CLI-provided identity to `~/.sovereign_browser/identity.json` (default: save, matching GTK dialog behavior). |
| `--login-timeout <ms>` | int | Override `settings.agent_login_timeout_ms`. Only meaningful when the GTK dialog is shown (no `--login-method`). |
### Diagnostics
| Flag | Arg | Description |
|---|---|---|
| `--verbose`, `-v` | — | Increase log verbosity (g_print messages). Repeatable for more detail. |
| `--quiet`, `-q` | — | Suppress non-error log output. |
## Usage Examples
```bash
# Existing behavior — still works
./sovereign_browser https://example.com
# Agent / CI: generated key, no dialog, open a page
./sovereign_browser --login-method generate --url https://example.com
# Local key from env, skip dialog, custom agent port
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no session restore, two tabs
./sovereign_browser --login-method readonly --npub npub1... \
--no-session-restore --url https://a.com --url https://b.com
# Seed phrase, account 1
./sovereign_browser --login-method seed \
--mnemonic "abandon abandon abandon ... about" --account 1
# NIP-46 remote signer
./sovereign_browser --login-method nip46 --bunker "bunker://..."
# n_signer hardware via qrexec
./sovereign_browser --login-method nsigner --nsigner-transport qrexec \
--nsigner-device nostr_signer --nsigner-service qubes.NsignerRpc
# Disable agent server, just browse
./sovereign_browser --no-agent --url https://example.com
# Version / help
./sovereign_browser --version
./sovereign_browser --help
```
## Implementation Plan
### Files Touched
| File | Change |
|---|---|
| `src/cli.h` (new) | Declares `cli_args_t` struct and `cli_parse()`. |
| `src/cli.c` (new) | Implements `getopt_long` parsing, validation, usage text, and `cli_login()` wrapper that builds cJSON and calls `agent_login()`. |
| `src/main.c` | Call `cli_parse(&argc, &argv)` **before** `gtk_init()`. Apply overrides to a mutable copy of settings. If `--login-method` was given, call `cli_login()` and skip `do_login()`. Replace `start_url` logic with the `--url` / positional list. |
| `Makefile` | Add `src/cli.o` to `OBJS`. |
| `README.md` | Document the flags. |
| `browser.sh` | Optional: accept extra args after `start`/`restart` and forward them to the binary (e.g. `./browser.sh start --login-method generate`). |
### Todo List
1. Rename "random" → "generate" in [`src/agent_login.c`](src/agent_login.c), [`src/agent_mcp.c`](src/agent_mcp.c), [`tests/test_agent_login.py`](tests/test_agent_login.py), [`README.md`](README.md), and `.roorules`. Rename internal `login_random()``login_generate()`.
2. Create `src/cli.h` with the `cli_args_t` struct and API.
3. Implement `src/cli.c`: `getopt_long` table, validation, `print_usage()`, `cli_login()` wrapper.
4. Wire `cli_parse()` into [`main()`](src/main.c:493) before `gtk_init()`; strip recognized flags from `argv`.
5. Apply CLI overrides to a mutable settings snapshot used for this run (port, agent enabled, max tabs, new-tab URL, session restore).
6. Implement `--url` (repeatable) + positional URL collection; pass list to tab manager at startup.
7. Implement `--login-method` path: build cJSON params, call `agent_login()`, check `success`, skip GTK dialog on success, exit 1 on failure.
8. Implement `--version` / `--help` (print and exit 0 before GTK init).
9. Implement `--no-save-identity` (skip `key_store_save()` on CLI login).
10. Update `Makefile` to compile `src/cli.c`.
11. Update `browser.sh` to forward extra args to the binary.
12. Update `README.md` with a CLI flags section.
13. Add a smoke test: `./sovereign_browser --login-method generate --no-agent --version` style checks (can be a shell test under `tests/`).
### Key Implementation Details
**Parse order:** `cli_parse()` must run before `gtk_init()` because GTK
aborts on unknown `--options`. `getopt_long` with `optind` lets us
repack `argv` so GTK only sees positional URLs. Pattern:
```c
cli_args_t args;
if (cli_parse(&argc, &argv, &args) != 0) {
return EXIT_FAILURE; /* --help / --version / parse error */
}
gtk_init(&argc, &argv); /* sees only positional URLs now */
```
**Login reuse:** `cli_login()` builds the exact cJSON shape documented in
[`agent_login.h`](src/agent_login.h:27) and calls `agent_login()`. On
`success: true`, the global state is already set via `app_set_signer()`,
so we set `g_logged_in = TRUE` and skip `do_login()`. On failure, print
the error message to stderr and exit non-zero.
**Settings override:** Introduce a `settings_apply_cli_overrides(const
cli_args_t *)` that mutates the global singleton in memory (not on disk)
after `settings_load()`. The rest of the code reads via
`settings_get()` unchanged.
**Repeatable `--url`:** Collect into a `GPtrArray` in `cli_args_t`.
After session restore fails (or is skipped), open each URL in its own
tab via `tab_manager_new_tab(url)`. If no URLs given, fall back to
`settings.new_tab_url`.
**`--no-save-identity`:** The GTK dialog path calls `key_store_save()`
after successful login. The CLI path should do the same by default so
the identity persists, but `--no-save-identity` skips it — useful for
ephemeral/CI runs that should not write to `~/.sovereign_browser/`.
## Mermaid: Startup Decision Flow
```mermaid
flowchart TD
A[main: cli_parse] --> B{help or version?}
B -- yes --> Z[print and exit 0]
B -- no --> C[gtk_init with stripped argv]
C --> D[settings_load + apply CLI overrides]
D --> E[agent_server_start unless --no-agent]
E --> F{login-method flag set?}
F -- yes --> G[cli_login: build cJSON, call agent_login]
G --> H{success?}
H -- no --> Y[print error, exit 1]
H -- yes --> I[optionally key_store_save unless --no-save-identity]
F -- no --> J[do_login: GTK dialog as today]
I --> K[session_restore unless --no-session-restore]
J --> K
K --> L{restored or URLs provided?}
L -- URLs --> M[open each --url / positional URL in a tab]
L -- none --> N[open settings.new_tab_url]
M --> O[gtk_main]
N --> O
```
## Open Questions
1. Should `--login-method generate` auto-set `--no-save-identity` by default
(since a generated key is usually ephemeral)? Proposal: **no** — keep
explicit, but document that random keys *will* be saved unless
`--no-save-identity` is passed.
2. Should we support reading `--nsec` / `--mnemonic` from a file path
(e.g. `--nsec-file /run/secrets/nsec`) to avoid leaking secrets in
`ps`/shell history? Proposal: defer to a follow-up; out of scope for
this plan but worth noting.
3. `--headless` (no GTK window, agent server only)? WebKitGTK requires a
display; true headless would need a virtual framebuffer or a non-WebKit
path. Out of scope for this plan.
+299
View File
@@ -0,0 +1,299 @@
# Plan: SQLite Integration, No-Login Mode, Bootstrap Relay Fetch
## Overview
Three related features that build on each other:
1. **SQLite** — persistent local storage for Nostr events and future data
2. **No-Login mode** — bypass Nostr login, use the browser as a normal browser
3. **Bootstrap relay fetch** — after login, fetch the user's kind 0/3/10002 events from configured relays and cache them in SQLite
```mermaid
flowchart TD
A[Browser startup] --> B{Login dialog}
B -->|Sign In| C[Login with Nostr key]
B -->|No Login| D[Skip Nostr — normal browser mode]
B -->|Cancel| E[Exit]
C --> F[Fetch kind 0, 3, 10002 from bootstrap relays]
F --> G[Store events in SQLite]
G --> H[Browser ready with identity + cached events]
D --> I[Browser ready — no identity, no relay fetch]
subgraph "SQLite database"
J[events table]
K[event_tags table]
L[relays table]
M[key_value table]
end
G --> J
G --> K
H --> M
```
---
## Phase 1: SQLite Integration
### Install dependency
```bash
sudo apt install libsqlite3-dev
```
This provides `sqlite3.h`, `libsqlite3.so`, and `sqlite3.pc` (pkg-config).
### New files: `src/db.h` / `src/db.c`
A thin wrapper around SQLite3 that provides:
```c
/* db.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
/* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables if they don't exist. Call once at startup. */
int db_init(void);
/* Close the database. Call at shutdown. */
void db_close(void);
/* ── Events ─────────────────────────────────────────────── */
/* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events table + tags table. */
int db_store_event(const cJSON *event);
/* Fetch the most recent event of a given kind for a pubkey.
* Returns a newly allocated cJSON event, or NULL if not found.
* Caller must cJSON_Delete() the result. */
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/* Fetch all events of a given kind for a pubkey (newest first).
* Returns a cJSON array. Caller must cJSON_Delete() the result. */
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
/* ── Key-Value store (for misc settings/cache) ──────────── */
int db_kv_set(const char *key, const char *value);
const char *db_kv_get(const char *key); /* returns pointer, valid until next db_kv_get call */
```
### Database schema
```sql
-- Nostr events (kind 0 = profile, 3 = contacts, 10002 = relay list, etc.)
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, -- event ID (hex, 64 chars)
pubkey TEXT NOT NULL, -- author pubkey (hex)
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL, -- unix timestamp
content TEXT, -- event content
sig TEXT, -- signature
raw_json TEXT NOT NULL, -- full event JSON for round-tripping
fetched_at INTEGER NOT NULL -- when we cached it
);
-- Event tags (for querying by tag values, e.g. relay URLs in kind 10002)
CREATE TABLE IF NOT EXISTS event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL, -- first element of tag array
tag_value TEXT, -- second element (if any)
position INTEGER NOT NULL, -- tag index in the event
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_tags_name_value ON event_tags(tag_name, tag_value);
-- Simple key-value store for misc data
CREATE TABLE IF NOT EXISTS key_value (
key TEXT PRIMARY KEY,
value TEXT
);
```
### Makefile changes
```make
CFLAGS += $(shell pkg-config --cflags sqlite3)
LDLIBS += $(shell pkg-config --libs sqlite3)
SRC += src/db.c
```
### Integration in `src/main.c`
- Call `db_init()` after `settings_load()` in `main()`.
- Call `db_close()` in `on_window_destroy()` before `gtk_main_quit()`.
---
## Phase 2: No-Login Mode
### Login dialog changes (`src/login_dialog.c`)
**Button layout** (action area, left to right):
1. `No Login` — far left (NEW)
2. `Cancel` — center
3. `Sign In` — right
**Changes:**
- Remove underscores from button labels: `"_Cancel"``"Cancel"`, `"_Sign In"``"Sign In"`.
- Add a third button: `GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");`
- Pack it first (far left): `gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);` before cancel and login buttons.
- Wire a new `on_no_login_clicked` handler that sets `ctx->done = TRUE` and responds with a new response code (e.g. `GTK_RESPONSE_OTHER` or a custom value like `GTK_RESPONSE_APPLY`).
**`login_result_t` changes (`src/login_dialog.h`):**
- The existing `method == KEY_STORE_METHOD_NONE` with `signer == NULL` already represents "no identity." The dialog will return success (0) with `method = KEY_STORE_METHOD_NONE` and empty `pubkey_hex`.
**`login_dialog_run` changes:**
- After `gtk_dialog_run` returns, check for the no-login response code. If it's the no-login response, set `result->method = KEY_STORE_METHOD_NONE`, clear the result, and return 0 (success).
### `src/main.c` changes
- In `do_login()`, after a successful login with `method == KEY_STORE_METHOD_NONE` and empty pubkey, set `g_state.readonly = TRUE` and `g_logged_in = TRUE` but don't set a signer. The browser works normally — `window.nostr` won't be available (the bridge will return "No identity loaded" for sign requests), but all normal browsing works.
- Skip the relay fetch (Phase 4) when no identity is loaded.
### CLI support (`src/cli.c` / `src/cli.h`)
- Add `--no-login` flag that skips the login dialog entirely (sets `g_logged_in = TRUE` with no signer). This is equivalent to clicking "No Login" but from the command line.
---
## Phase 3: Bootstrap Relays in Settings
### Settings struct (`src/settings.h` / `src/settings.c`)
Add a new field to `browser_settings_t`:
```c
#define SETTINGS_BOOTSTRAP_RELAYS_MAX 2048
// ...
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
```
Default value in `settings_set_defaults()`:
```
wss://laantungir.net/relay\nwss://relay.primal.net\nwss://relay.damus.io
```
Parse/save in `settings_load()` / `settings_save()` as `bootstrap_relays=...` (URL-encoded or newline-separated, stored as a single key=value line).
### Settings page (`src/nostr_bridge.c`)
Add a new section **"Bootstrap Relays"** to the `sovereign://settings` page (between Agent Server and Security):
```html
<h2>Bootstrap Relays</h2>
<p class='note'>Relays queried after login to fetch your profile (kind 0),
contacts (kind 3), and relay list (kind 10002). One URL per line.</p>
<div class='field'>
<div><div class='setting-name'>Relay URLs</div>
<div class='setting-desc'>One wss:// URL per line</div></div>
<div><textarea id='bootstrap_relays' rows='4' cols='40'>...</textarea>
<button class='save-btn' onclick="save('bootstrap_relays')">Save</button></div>
</div>
```
The `handle_settings_set` key/value path needs a new case for `bootstrap_relays` that stores the newline-separated value into `bs->bootstrap_relays` and calls `settings_save()`.
---
## Phase 4: Post-Login Relay Fetch
### New file: `src/relay_fetch.h` / `src/relay_fetch.c`
```c
/* relay_fetch.h */
#pragma once
#include "../nostr_core_lib/cjson/cJSON.h"
/* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays.
* Stores results in the SQLite database via db_store_event().
* Runs synchronously (called from a background thread or with a timeout).
*
* pubkey_hex — user's hex pubkey
* relay_urls — NULL-terminated array of relay URLs
* relay_count — number of relays
*
* Returns the number of events fetched and stored, or -1 on error. */
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count);
```
### Implementation (`src/relay_fetch.c`)
Uses `synchronous_query_relays_with_progress()` from `nostr_core_lib`:
1. Parse `bootstrap_relays` from settings into an array of URLs.
2. Build a cJSON filter: `{"authors": [pubkey], "kinds": [0, 3, 10002]}`.
3. Call `synchronous_query_relays_with_progress(relay_urls, relay_count, filter, RELAY_QUERY_ALL_RESULTS, &result_count, 15, NULL, NULL, 0)`.
4. For each returned event, call `db_store_event(event)`.
5. Log the results: `[relay] Fetched N events from M relays`.
6. Free the results array and filter.
### Integration in `src/main.c`
After successful login (in `do_login()` or right after it returns), if a signer/pubkey is available:
```c
if (g_state.pubkey_hex[0] != '\0') {
/* Parse bootstrap relays from settings. */
/* Call relay_fetch_bootstrap() in a background thread to avoid
* blocking the UI. Use g_thread_new() or a GTask. */
g_thread_new("relay-fetch", relay_fetch_thread, NULL);
}
```
The fetch runs in a background thread so the browser is usable immediately. Events are stored in SQLite as they arrive. A future enhancement could notify the UI when the fetch completes.
### Threading consideration
`nostr_core_lib`'s `synchronous_query_relays_with_progress` is a blocking call that uses its own WebSocket event loop. It should be safe to call from a background thread as long as we don't touch GTK widgets from that thread. The `db_store_event()` calls only touch SQLite (which is thread-safe with proper locking — we'll use `sqlite3_mutex` or open the DB with `SQLITE_OPEN_FULLMUTEX`).
---
## Phase 5: Build, Verify, Test
1. `sudo apt install libsqlite3-dev`
2. `make` — verify clean build
3. Test No-Login mode:
- `./browser.sh start --no-login`
- Verify browser opens without login dialog, normal browsing works
- Verify `window.nostr` returns "No identity loaded" errors
4. Test login + relay fetch:
- `./browser.sh start --login-method generate`
- Verify log shows `[relay] Fetched N events from M relays`
- Verify `~/.sovereign_browser/browser.db` contains events
5. Test settings page:
- Navigate to `sovereign://settings`
- Verify "Bootstrap Relays" section appears with the 3 default relays
- Edit relays, save, restart, verify persistence
6. Test login dialog buttons:
- Verify "No Login" button is far left
- Verify "Cancel" and "Sign In" labels have no underscores
---
## File change summary
| File | Change |
|------|--------|
| `src/db.h` | NEW — SQLite wrapper API |
| `src/db.c` | NEW — SQLite implementation (events, tags, key-value) |
| `src/relay_fetch.h` | NEW — relay fetch API |
| `src/relay_fetch.c` | NEW — fetch kind 0/3/10002 from bootstrap relays |
| `src/settings.h` | Add `bootstrap_relays` field + max constant |
| `src/settings.c` | Parse/save `bootstrap_relays` with defaults |
| `src/nostr_bridge.c` | Add "Bootstrap Relays" section to settings page + set handler |
| `src/login_dialog.c` | Add "No Login" button, remove underscores, new handler |
| `src/login_dialog.h` | Document no-login result (method=NONE, empty pubkey) |
| `src/main.c` | Call `db_init()`/`db_close()`, handle no-login result, trigger relay fetch |
| `src/cli.h` | Add `--no-login` flag |
| `src/cli.c` | Parse `--no-login`, skip login dialog |
| `Makefile` | Add `sqlite3` to pkg-config, add `src/db.c` + `src/relay_fetch.c` to SRC |
+46 -2
View File
@@ -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
View File
@@ -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",
+481
View File
@@ -0,0 +1,481 @@
/*
* cli.c — command-line flag parsing for sovereign_browser
*
* Uses getopt_long to parse flags before gtk_init(). Recognized flags
* are stripped from argv (getopt_long reorders argv in place; optind
* points at the first positional arg on return). We collect positional
* args as startup URLs.
*
* Login flags build a cJSON params object matching the shape documented
* in agent_login.h and call agent_login(), reusing the same code path
* as the MCP 'login' tool.
*/
#define _POSIX_C_SOURCE 200809L /* strdup, getopt_long */
#include "cli.h"
#include "version.h"
#include "agent_login.h"
#include "key_store.h"
#include "nostr_core/nostr_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <getopt.h>
/* ── Usage text ────────────────────────────────────────────────────── */
void cli_print_usage(FILE *fp) {
fprintf(fp,
"sovereign_browser %s — WebKitGTK + C99\n"
"\n"
"Usage: sovereign_browser [OPTIONS] [URL...]\n"
"\n"
"Positional URLs are opened in tabs at startup (unless a session is\n"
"restored). --url flags are equivalent and may be repeated.\n"
"\n"
"Browser / Startup:\n"
" --url <url> Open URL in a tab (repeatable)\n"
" --new-tab-url <url> Override the default new-tab URL\n"
" --no-session-restore Skip session restore for this run\n"
" --session-restore Force session restore even if disabled\n"
" --max-tabs <n> Override max simultaneous tabs\n"
" -V, --version Print version and exit\n"
" -h, --help Show this help and exit\n"
"\n"
"Agent Server:\n"
" --port <port> Override agent MCP server port\n"
" --no-agent Disable the agent server for this run\n"
" --agent Force-enable the agent server\n"
" --agent-origin <origin> Allowed origin (repeatable)\n"
"\n"
"Login (skips the GTK login dialog):\n"
" --no-login Browse without a Nostr identity (normal browser mode)\n"
" --login-method <m> Method: generate|local|seed|readonly|nip46|nsigner\n"
" --nsec <nsec1...> (local) nsec bech32 private key\n"
" --privkey <hex> (local) 64-char hex private key\n"
" --mnemonic <words> (seed) BIP-39 mnemonic, quoted\n"
" --account <n> (seed) BIP-44 account index (default 0)\n"
" --npub <npub1...> (readonly) npub bech32 public key\n"
" --pubkey <hex> (readonly) 64-char hex pubkey\n"
" --bunker <url> (nip46) bunker:// URL\n"
" --nsigner-transport <t> (nsigner) serial|unix|tcp|qrexec\n"
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
" --nsigner-service <name> (nsigner) qrexec service name\n"
" --nsigner-index <n> (nsigner) key index (default 0)\n"
" --no-save-identity Do not persist identity to disk\n"
" --login-timeout <ms> Override agent login timeout (GTK dialog path)\n"
"\n"
"Diagnostics:\n"
" -v, --verbose Increase log verbosity (repeatable)\n"
" -q, --quiet Suppress non-error log output\n"
"\n"
"Examples:\n"
" sovereign_browser https://example.com\n"
" sovereign_browser --login-method generate --url https://example.com\n"
" sovereign_browser --login-method local --nsec nsec1... --port 18888\n"
" sovereign_browser --login-method readonly --npub npub1... --no-agent\n"
" sovereign_browser --no-agent --url https://a.com --url https://b.com\n",
SB_VERSION);
}
/* ── getopt_long option table ──────────────────────────────────────── */
enum {
OPT_URL = 1000,
OPT_NEW_TAB_URL,
OPT_NO_SESSION_RESTORE,
OPT_SESSION_RESTORE,
OPT_MAX_TABS,
OPT_VERSION,
OPT_PORT,
OPT_NO_AGENT,
OPT_AGENT,
OPT_AGENT_ORIGIN,
OPT_LOGIN_METHOD,
OPT_NSEC,
OPT_PRIVKEY,
OPT_MNEMONIC,
OPT_ACCOUNT,
OPT_NPUB,
OPT_PUBKEY,
OPT_BUNKER,
OPT_NSIGNER_TRANSPORT,
OPT_NSIGNER_DEVICE,
OPT_NSIGNER_SERVICE,
OPT_NSIGNER_INDEX,
OPT_NO_SAVE_IDENTITY,
OPT_NO_LOGIN,
OPT_LOGIN_TIMEOUT,
OPT_VERBOSE,
OPT_QUIET,
OPT_HELP,
};
static struct option long_opts[] = {
/* Browser / startup */
{"url", required_argument, 0, OPT_URL},
{"new-tab-url", required_argument, 0, OPT_NEW_TAB_URL},
{"no-session-restore", no_argument, 0, OPT_NO_SESSION_RESTORE},
{"session-restore", no_argument, 0, OPT_SESSION_RESTORE},
{"max-tabs", required_argument, 0, OPT_MAX_TABS},
{"version", no_argument, 0, OPT_VERSION},
/* Agent server */
{"port", required_argument, 0, OPT_PORT},
{"no-agent", no_argument, 0, OPT_NO_AGENT},
{"agent", no_argument, 0, OPT_AGENT},
{"agent-origin", required_argument, 0, OPT_AGENT_ORIGIN},
/* Login */
{"login-method", required_argument, 0, OPT_LOGIN_METHOD},
{"nsec", required_argument, 0, OPT_NSEC},
{"privkey", required_argument, 0, OPT_PRIVKEY},
{"mnemonic", required_argument, 0, OPT_MNEMONIC},
{"account", required_argument, 0, OPT_ACCOUNT},
{"npub", required_argument, 0, OPT_NPUB},
{"pubkey", required_argument, 0, OPT_PUBKEY},
{"bunker", required_argument, 0, OPT_BUNKER},
{"nsigner-transport", required_argument, 0, OPT_NSIGNER_TRANSPORT},
{"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE},
{"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE},
{"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX},
{"no-save-identity", no_argument, 0, OPT_NO_SAVE_IDENTITY},
{"no-login", no_argument, 0, OPT_NO_LOGIN},
{"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT},
/* Diagnostics */
{"verbose", no_argument, 0, OPT_VERBOSE},
{"quiet", no_argument, 0, OPT_QUIET},
{"help", no_argument, 0, OPT_HELP},
{0, 0, 0, 0}
};
static const char *short_opts = "vVhq";
/* ── Helpers ───────────────────────────────────────────────────────── */
static char *xstrdup(const char *s) {
if (s == NULL) return NULL;
char *p = strdup(s);
if (p == NULL) {
fprintf(stderr, "[cli] out of memory\n");
exit(EXIT_FAILURE);
}
return p;
}
static void add_string(char ***arr, int *count, const char *value) {
*arr = g_realloc(*arr, sizeof(char *) * (*count + 1));
(*arr)[*count] = xstrdup(value);
(*count)++;
}
static int valid_login_method(const char *m) {
return strcmp(m, "generate") == 0 ||
strcmp(m, "local") == 0 ||
strcmp(m, "seed") == 0 ||
strcmp(m, "readonly") == 0 ||
strcmp(m, "nip46") == 0 ||
strcmp(m, "nsigner") == 0;
}
/* ── cli_args_free ─────────────────────────────────────────────────── */
void cli_args_free(cli_args_t *args) {
if (args == NULL) return;
if (args->urls) {
for (int i = 0; i < args->url_count; i++) g_free(args->urls[i]);
g_free(args->urls);
}
g_free(args->new_tab_url);
if (args->agent_origins) {
for (int i = 0; i < args->agent_origin_count; i++) g_free(args->agent_origins[i]);
g_free(args->agent_origins);
}
g_free(args->login_method);
g_free(args->nsec);
g_free(args->privkey);
g_free(args->mnemonic);
g_free(args->npub);
g_free(args->pubkey);
g_free(args->bunker);
g_free(args->nsigner_transport);
g_free(args->nsigner_device);
g_free(args->nsigner_service);
memset(args, 0, sizeof(*args));
}
/* ── cli_parse ─────────────────────────────────────────────────────── */
int cli_parse(int *argc, char ***argv, cli_args_t *out) {
memset(out, 0, sizeof(*out));
out->account = -1;
out->nsigner_index = -1;
/* getopt_long reorders argv so non-options end up at the end. */
opterr = 0; /* we print our own errors */
optind = 1;
int c;
int long_idx;
while ((c = getopt_long(*argc, *argv, short_opts, long_opts, &long_idx)) != -1) {
switch (c) {
/* Short options */
case 'v': out->verbose++; break;
case 'V': out->want_version = TRUE; return 0;
case 'h': out->want_help = TRUE; return 0;
case 'q': out->quiet = TRUE; break;
case '?':
if (optopt) {
fprintf(stderr, "[cli] unknown short option '-%c'\n", optopt);
} else {
fprintf(stderr, "[cli] unknown long option near '%s'\n",
(*argv)[optind - 1] ? (*argv)[optind - 1] : "?");
}
cli_print_usage(stderr);
return -1;
/* Browser / startup */
case OPT_URL:
add_string(&out->urls, &out->url_count, optarg);
break;
case OPT_NEW_TAB_URL:
g_free(out->new_tab_url);
out->new_tab_url = xstrdup(optarg);
break;
case OPT_NO_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_FALSE;
break;
case OPT_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_TRUE;
break;
case OPT_MAX_TABS:
out->max_tabs = atoi(optarg);
if (out->max_tabs < 1) {
fprintf(stderr, "[cli] --max-tabs must be >= 1\n");
return -1;
}
break;
case OPT_VERSION:
out->want_version = TRUE;
return 0;
/* Agent server */
case OPT_PORT:
out->agent_port = atoi(optarg);
if (out->agent_port < 1 || out->agent_port > 65535) {
fprintf(stderr, "[cli] --port must be 1-65535\n");
return -1;
}
break;
case OPT_NO_AGENT:
out->agent_enabled = CLI_TRISTATE_FALSE;
break;
case OPT_AGENT:
out->agent_enabled = CLI_TRISTATE_TRUE;
break;
case OPT_AGENT_ORIGIN:
add_string(&out->agent_origins, &out->agent_origin_count, optarg);
break;
/* Login */
case OPT_LOGIN_METHOD:
if (!valid_login_method(optarg)) {
fprintf(stderr, "[cli] invalid --login-method '%s'\n", optarg);
fprintf(stderr, "[cli] use: generate|local|seed|readonly|nip46|nsigner\n");
return -1;
}
g_free(out->login_method);
out->login_method = xstrdup(optarg);
break;
case OPT_NSEC:
g_free(out->nsec);
out->nsec = xstrdup(optarg);
break;
case OPT_PRIVKEY:
g_free(out->privkey);
out->privkey = xstrdup(optarg);
break;
case OPT_MNEMONIC:
g_free(out->mnemonic);
out->mnemonic = xstrdup(optarg);
break;
case OPT_ACCOUNT:
out->account = atoi(optarg);
if (out->account < 0) {
fprintf(stderr, "[cli] --account must be >= 0\n");
return -1;
}
break;
case OPT_NPUB:
g_free(out->npub);
out->npub = xstrdup(optarg);
break;
case OPT_PUBKEY:
g_free(out->pubkey);
out->pubkey = xstrdup(optarg);
break;
case OPT_BUNKER:
g_free(out->bunker);
out->bunker = xstrdup(optarg);
break;
case OPT_NSIGNER_TRANSPORT:
g_free(out->nsigner_transport);
out->nsigner_transport = xstrdup(optarg);
break;
case OPT_NSIGNER_DEVICE:
g_free(out->nsigner_device);
out->nsigner_device = xstrdup(optarg);
break;
case OPT_NSIGNER_SERVICE:
g_free(out->nsigner_service);
out->nsigner_service = xstrdup(optarg);
break;
case OPT_NSIGNER_INDEX:
out->nsigner_index = atoi(optarg);
if (out->nsigner_index < 0) {
fprintf(stderr, "[cli] --nsigner-index must be >= 0\n");
return -1;
}
break;
case OPT_NO_SAVE_IDENTITY:
out->no_save_identity = TRUE;
break;
case OPT_NO_LOGIN:
out->no_login = TRUE;
break;
case OPT_LOGIN_TIMEOUT:
out->login_timeout_ms = atoi(optarg);
if (out->login_timeout_ms < 0) {
fprintf(stderr, "[cli] --login-timeout must be >= 0\n");
return -1;
}
break;
/* Diagnostics */
case OPT_VERBOSE:
out->verbose++;
break;
case OPT_QUIET:
out->quiet = TRUE;
break;
case OPT_HELP:
out->want_help = TRUE;
return 0;
default:
fprintf(stderr, "[cli] unhandled option\n");
return -1;
}
}
/* Validate login-method-specific args. */
if (out->login_method) {
const char *m = out->login_method;
if (strcmp(m, "local") == 0) {
if (!out->nsec && !out->privkey) {
fprintf(stderr, "[cli] --login-method local requires --nsec or --privkey\n");
return -1;
}
} else if (strcmp(m, "seed") == 0) {
if (!out->mnemonic) {
fprintf(stderr, "[cli] --login-method seed requires --mnemonic\n");
return -1;
}
} else if (strcmp(m, "readonly") == 0) {
if (!out->npub && !out->pubkey) {
fprintf(stderr, "[cli] --login-method readonly requires --npub or --pubkey\n");
return -1;
}
} else if (strcmp(m, "nip46") == 0) {
if (!out->bunker) {
fprintf(stderr, "[cli] --login-method nip46 requires --bunker\n");
return -1;
}
} else if (strcmp(m, "nsigner") == 0) {
if (!out->nsigner_transport || !out->nsigner_device) {
fprintf(stderr, "[cli] --login-method nsigner requires --nsigner-transport and --nsigner-device\n");
return -1;
}
}
/* "generate" needs no extra args. */
}
/* Collect positional args (after getopt_long reordering) as URLs. */
for (int i = optind; i < *argc; i++) {
add_string(&out->urls, &out->url_count, (*argv)[i]);
}
/* Repack argv: keep argv[0], then positional args only. gtk_init()
* will see a clean vector with no unknown options. */
int new_argc = 1; /* argv[0] */
for (int i = optind; i < *argc; i++) {
(*argv)[new_argc++] = (*argv)[i];
}
(*argv)[new_argc] = NULL;
*argc = new_argc;
return 0;
}
/* ── cli_login ─────────────────────────────────────────────────────── */
int cli_login(const cli_args_t *args) {
if (args->login_method == NULL) {
fprintf(stderr, "[cli] cli_login called with no --login-method\n");
return -1;
}
/* Build the cJSON params object matching agent_login.h's contract. */
cJSON *params = cJSON_CreateObject();
cJSON_AddStringToObject(params, "method", args->login_method);
if (args->nsec) cJSON_AddStringToObject(params, "nsec", args->nsec);
if (args->privkey) cJSON_AddStringToObject(params, "privkey_hex", args->privkey);
if (args->mnemonic) cJSON_AddStringToObject(params, "mnemonic", args->mnemonic);
if (args->account >= 0) cJSON_AddNumberToObject(params, "account", args->account);
if (args->npub) cJSON_AddStringToObject(params, "npub", args->npub);
if (args->pubkey) cJSON_AddStringToObject(params, "pubkey_hex", args->pubkey);
if (args->bunker) cJSON_AddStringToObject(params, "bunker_url", args->bunker);
if (args->nsigner_transport) cJSON_AddStringToObject(params, "transport", args->nsigner_transport);
if (args->nsigner_device) cJSON_AddStringToObject(params, "device", args->nsigner_device);
if (args->nsigner_service) cJSON_AddStringToObject(params, "service", args->nsigner_service);
if (args->nsigner_index >= 0) cJSON_AddNumberToObject(params, "index", args->nsigner_index);
/* agent_login() calls app_set_signer() on success, which sets the
* same global state the GTK dialog would. It also handles
* nostr_init() internally. */
cJSON *result = agent_login(params);
cJSON_Delete(params);
if (result == NULL) {
fprintf(stderr, "[cli] login returned NULL\n");
return -1;
}
cJSON *success = cJSON_GetObjectItem(result, "success");
if (!success || !cJSON_IsTrue(success)) {
cJSON *err = cJSON_GetObjectItem(result, "error");
const char *code = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "code")) : "?";
const char *msg = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "message")) : "unknown";
fprintf(stderr, "[cli] login failed: %s: %s\n", code ? code : "?", msg ? msg : "?");
cJSON_Delete(result);
return -1;
}
/* Login succeeded. Print the pubkey for visibility. */
cJSON *data = cJSON_GetObjectItem(result, "data");
if (data) {
const char *pubkey = cJSON_GetStringValue(cJSON_GetObjectItem(data, "pubkey"));
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(data, "method"));
g_print("[cli] Logged in: method=%s pubkey=%s\n",
method ? method : "?", pubkey ? pubkey : "?");
}
cJSON_Delete(result);
/* TODO: persist identity via key_store_save() when that path is
* wired up. For now --no-save-identity is accepted but the save
* itself is a no-op (key_store_save is not called anywhere yet). */
(void)args->no_save_identity;
return 0;
}
+123
View File
@@ -0,0 +1,123 @@
/*
* cli.h — command-line flag parsing for sovereign_browser
*
* Parses argv before gtk_init() so GTK doesn't abort on our flags.
* Recognized flags are stripped from argv/argc so gtk_init() only sees
* positional URLs. CLI flags override settings.conf for this run only
* (nothing is written to disk).
*
* Login flags build a cJSON params object and call agent_login(),
* reusing the same code path as the MCP 'login' tool. On success the
* GTK login dialog is skipped entirely.
*/
#ifndef CLI_H
#define CLI_H
#include <glib.h>
#include <stdio.h> /* FILE */
#ifdef __cplusplus
extern "C" {
#endif
/* Tri-state for boolean overrides. */
typedef enum {
CLI_TRISTATE_UNSET = 0,
CLI_TRISTATE_TRUE,
CLI_TRISTATE_FALSE
} cli_tristate_t;
/* Parsed command-line arguments. Zero-initialized by cli_parse().
* Strings are heap-allocated and owned by the cli_args_t — free with
* cli_args_free(). */
typedef struct {
/* Startup URLs: --url flags + positional args, in order. */
char **urls;
int url_count;
/* Browser overrides (NULL / 0 = not set). */
char *new_tab_url; /* --new-tab-url */
int max_tabs; /* --max-tabs (0 = unset) */
cli_tristate_t session_restore; /* --session-restore / --no-session-restore */
/* Agent server overrides. */
int agent_port; /* --port (0 = unset) */
cli_tristate_t agent_enabled; /* --agent / --no-agent */
char **agent_origins; /* --agent-origin (repeatable) */
int agent_origin_count;
/* Login. login_method == NULL means "show GTK dialog".
* no_login == TRUE means skip login entirely (browse without identity). */
gboolean no_login; /* --no-login */
char *login_method; /* --login-method */
char *nsec; /* --nsec */
char *privkey; /* --privkey */
char *mnemonic; /* --mnemonic */
int account; /* --account (-1 = unset, defaults to 0) */
char *npub; /* --npub */
char *pubkey; /* --pubkey */
char *bunker; /* --bunker */
char *nsigner_transport; /* --nsigner-transport */
char *nsigner_device; /* --nsigner-device */
char *nsigner_service; /* --nsigner-service */
int nsigner_index; /* --nsigner-index (-1 = unset) */
gboolean no_save_identity; /* --no-save-identity */
int login_timeout_ms; /* --login-timeout (0 = unset) */
/* Diagnostics. */
int verbose; /* --verbose / -v (repeatable count) */
gboolean quiet; /* --quiet / -q */
/* Exit-request flags set by --help / --version. */
gboolean want_help;
gboolean want_version;
} cli_args_t;
/*
* Parse command-line arguments.
*
* On entry, *argc / *argv are the raw values from main(). On return,
* recognized flags are removed from *argv / *argc (repacked via
* getopt_long's optind) so the remaining vector is safe to pass to
* gtk_init(). Positional URL arguments are preserved.
*
* Returns:
* 0 — parse OK (or --help / --version requested; check args.want_*).
* -1 — parse error (message printed to stderr).
*
* On error or when want_help/want_version is true, the caller should
* exit without calling gtk_init().
*/
int cli_parse(int *argc, char ***argv, cli_args_t *out);
/*
* Free heap-allocated fields in a cli_args_t. Safe to call on a
* zero-initialized struct.
*/
void cli_args_free(cli_args_t *args);
/*
* Print usage text to the given stream.
*/
void cli_print_usage(FILE *fp);
/*
* Perform CLI login by building a cJSON params object from args and
* calling agent_login(). Requires nostr_init() to have been called
* already (agent_login() handles re-init internally).
*
* Returns:
* 0 — login succeeded (global app state is now set).
* -1 — login failed (error message printed to stderr).
*
* If args->no_save_identity is FALSE, the identity is persisted to
* ~/.sovereign_browser/identity.json via key_store_save() on success.
*/
int cli_login(const cli_args_t *args);
#ifdef __cplusplus
}
#endif
#endif /* CLI_H */
+404
View File
@@ -0,0 +1,404 @@
/*
* db.c — SQLite storage for sovereign_browser
*
* Stores Nostr events and misc data in ~/.sovereign_browser/browser.db.
* Uses SQLITE_OPEN_FULLMUTEX for thread safety (the relay fetch runs in
* a background thread).
*/
#include "db.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
/* ── Global state ──────────────────────────────────────────────────── */
static sqlite3 *g_db = NULL;
/* Static buffer for db_kv_get() — valid until the next call. */
static char g_kv_buf[4096];
/* ── Path helper ───────────────────────────────────────────────────── */
static int db_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/browser.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Schema ────────────────────────────────────────────────────────── */
static const char *SCHEMA_SQL =
"CREATE TABLE IF NOT EXISTS events ("
" id TEXT PRIMARY KEY,"
" pubkey TEXT NOT NULL,"
" kind INTEGER NOT NULL,"
" created_at INTEGER NOT NULL,"
" content TEXT,"
" sig TEXT,"
" raw_json TEXT NOT NULL,"
" fetched_at INTEGER NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS event_tags ("
" event_id TEXT NOT NULL,"
" tag_name TEXT NOT NULL,"
" tag_value TEXT,"
" position INTEGER NOT NULL,"
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
");"
"CREATE TABLE IF NOT EXISTS key_value ("
" key TEXT PRIMARY KEY,"
" value TEXT"
");"
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
" ON events(pubkey, kind, created_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
" ON event_tags(tag_name, tag_value);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
" ON event_tags(event_id);";
/* ── Init / Close ──────────────────────────────────────────────────── */
int db_init(void) {
char path[512];
if (db_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get database path\n");
return -1;
}
int rc = sqlite3_open_v2(path, &g_db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to open database: %s\n",
sqlite3_errmsg(g_db));
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
}
return -1;
}
/* Set busy timeout to 5 seconds (in case of contention). */
sqlite3_busy_timeout(g_db, 5000);
/* Enable foreign keys (for ON DELETE CASCADE on event_tags). */
char *err = NULL;
rc = sqlite3_exec(g_db, "PRAGMA foreign_keys = ON;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to enable foreign keys: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
}
/* Create schema. */
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to create schema: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
sqlite3_close(g_db);
g_db = NULL;
return -1;
}
g_print("[db] Database initialized at %s\n", path);
return 0;
}
void db_close(void) {
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
g_print("[db] Database closed\n");
}
}
/* ── Events ────────────────────────────────────────────────────────── */
/* Helper: get a string field from a cJSON event, or "" if missing. */
static const char *event_str(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsString(item)) return item->valuestring;
return "";
}
/* Helper: get an integer field from a cJSON event, or 0 if missing. */
static long event_int(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsNumber(item)) return (long)item->valuedouble;
return 0;
}
int db_store_event(const cJSON *event) {
if (g_db == NULL || event == NULL) return -1;
const char *id = event_str(event, "id");
const char *pubkey = event_str(event, "pubkey");
long kind = event_int(event, "kind");
long created = event_int(event, "created_at");
const char *content = event_str(event, "content");
const char *sig = event_str(event, "sig");
if (id[0] == '\0' || pubkey[0] == '\0') {
g_printerr("[db] Event missing id or pubkey\n");
return -1;
}
/* Serialize the full event to JSON for round-tripping. */
char *raw_json = cJSON_PrintUnformatted(event);
if (raw_json == NULL) return -1;
/* Begin transaction. */
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] BEGIN failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
/* Upsert the event (INSERT OR REPLACE). */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO events "
"(id, pubkey, kind, created_at, content, sig, raw_json, fetched_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'));",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, kind);
sqlite3_bind_int64(stmt, 4, created);
sqlite3_bind_text(stmt, 5, content, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, sig, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 7, raw_json, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] Events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
/* Delete old tags for this event (if replacing) and insert new ones. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Insert tags. */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
rc = sqlite3_prepare_v2(g_db,
"INSERT INTO event_tags (event_id, tag_name, tag_value, position) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
int pos = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name_item = cJSON_GetArrayItem(tag, 0);
cJSON *value_item = cJSON_GetArrayItem(tag, 1);
const char *tag_name = (name_item && cJSON_IsString(name_item))
? name_item->valuestring : "";
const char *tag_value = (value_item && cJSON_IsString(value_item))
? value_item->valuestring : "";
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, tag_name, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, tag_value, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 4, pos);
sqlite3_step(stmt);
sqlite3_reset(stmt);
pos++;
}
sqlite3_finalize(stmt);
}
}
/* Commit. */
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] COMMIT failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
free(raw_json);
return 0;
}
cJSON *db_get_latest_event(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare latest_event failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
cJSON *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
result = cJSON_Parse(json_str);
}
}
sqlite3_finalize(stmt);
return result;
}
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
/* If limit is 0, use a large default. */
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
sqlite3_bind_int(stmt, 3, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_count_events(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT COUNT(*) FROM events WHERE pubkey = ? AND kind = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
int count = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return count;
}
/* ── Key-Value store ───────────────────────────────────────────────── */
int db_kv_set(const char *key, const char *value) {
if (g_db == NULL || key == NULL || value == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
const char *db_kv_get(const char *key) {
if (g_db == NULL || key == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT value FROM key_value WHERE key = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return NULL;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
const char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *val = (const char *)sqlite3_column_text(stmt, 0);
if (val) {
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
result = g_kv_buf;
}
}
sqlite3_finalize(stmt);
return result;
}
char *db_kv_get_copy(const char *key) {
const char *val = db_kv_get(key);
if (val == NULL) return NULL;
return g_strdup(val);
}
+105
View File
@@ -0,0 +1,105 @@
/*
* db.h — SQLite storage for sovereign_browser
*
* Provides persistent local storage for Nostr events and misc data.
* The database lives at ~/.sovereign_browser/browser.db.
*
* Tables:
* events — Nostr events (id, pubkey, kind, created_at, content, sig, raw_json)
* event_tags — Tag rows for querying by tag name/value
* key_value — Simple key-value store for misc settings/cache
*
* Thread safety: the database is opened with SQLITE_OPEN_FULLMUTEX, so
* calls from multiple threads are safe (serialized by SQLite's mutex).
*/
#ifndef DB_H
#define DB_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables and indexes if they don't exist.
* Call once at startup (after settings_load()).
*
* Returns 0 on success, -1 on error.
*/
int db_init(void);
/*
* Close the database. Call at shutdown.
*/
void db_close(void);
/* ── Events ────────────────────────────────────────────────────────── */
/*
* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events + event_tags tables.
*
* event — a cJSON object representing a Nostr event with at least:
* id, pubkey, kind, created_at, content, sig, tags
*
* Returns 0 on success, -1 on error.
*/
int db_store_event(const cJSON *event);
/*
* Fetch the most recent event of a given kind for a pubkey.
*
* Returns a newly allocated cJSON event (parsed from raw_json), or NULL
* if not found. Caller must cJSON_Delete() the result.
*/
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/*
* Fetch all events of a given kind for a pubkey, newest first.
*
* limit — max number of events (0 = no limit)
*
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
* Returns NULL on error.
*/
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
/*
* Count events of a given kind for a pubkey.
* Returns the count, or -1 on error.
*/
int db_count_events(const char *pubkey_hex, int kind);
/* ── Key-Value store ───────────────────────────────────────────────── */
/*
* Set a key-value pair (upsert).
* Returns 0 on success, -1 on error.
*/
int db_kv_set(const char *key, const char *value);
/*
* Get a value by key.
* Returns a pointer to the value string, or NULL if not found.
* The pointer is valid until the next db_kv_get() call (uses a static
* buffer). Call db_kv_get_copy() if you need a persistent copy.
*/
const char *db_kv_get(const char *key);
/*
* Get a value by key, returning a newly allocated copy.
* Caller must g_free() the result. Returns NULL if not found.
*/
char *db_kv_get_copy(const char *key);
#ifdef __cplusplus
}
#endif
#endif /* DB_H */
+7 -2
View File
@@ -98,8 +98,13 @@ void history_add(const char *url) {
return;
}
/* Skip sovereign:// internal pages. */
if (strncmp(url, "sovereign://", 12) == 0) {
/* Skip sovereign:// API endpoints (not pages the user would want
* in their recents). The actual pages — sovereign://settings,
* sovereign://profile, sovereign://security — are kept in history
* so the user can quickly return to them. */
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
strncmp(url, "sovereign://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;
}
+26 -3
View File
@@ -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
View File
@@ -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
View File
@@ -8,12 +8,18 @@
#include "nostr_bridge.h"
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <stdlib.h>
#include <stdio.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip019.h"
#include "nostr_core/nip021.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include "settings.h"
#include "tab_manager.h"
#include "qr.h"
#include "db.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
@@ -93,6 +99,22 @@ static void respond_error_json(WebKitURISchemeRequest *request,
g_object_unref(stream);
}
/* Finish a scheme request with raw bytes and a given MIME type.
* Takes ownership of the GBytes (unrefs it after finishing). */
static void respond_bytes(WebKitURISchemeRequest *request, GBytes *bytes,
const char *mime_type) {
if (bytes == NULL) {
respond_error_json(request, -1, "Failed to generate content");
return;
}
gsize len = g_bytes_get_size(bytes);
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
g_bytes_unref(bytes);
webkit_uri_scheme_request_finish(request, stream, len, mime_type);
g_object_unref(stream);
}
/* ── Method handlers ────────────────────────────────────────────── */
static void handle_get_public_key(WebKitURISchemeRequest *request) {
@@ -337,11 +359,38 @@ static void handle_get_relays(WebKitURISchemeRequest *request) {
free(json);
}
/* ── Security settings page ────────────────────────────────────── */
/* ── Settings page ─────────────────────────────────────────────── */
/* Helper: extract a query parameter value (key=...) from a query string.
* Returns a newly allocated string (g_free) or NULL if not found. */
static char *query_param(const char *query, const char *key) {
if (query == NULL || key == NULL) return NULL;
size_t klen = strlen(key);
const char *p = query;
while (*p) {
if (strncmp(p, key, klen) == 0 && p[klen] == '=') {
const char *v = p + klen + 1;
const char *end = strchr(v, '&');
size_t vlen = end ? (size_t)(end - v) : strlen(v);
char *val = g_malloc(vlen + 1);
memcpy(val, v, vlen);
val[vlen] = '\0';
/* URL-decode. */
char *decoded = g_uri_unescape_string(val, NULL);
g_free(val);
return decoded;
}
/* Advance to next param. */
const char *amp = strchr(p, '&');
if (amp == NULL) break;
p = amp + 1;
}
return NULL;
}
/* Generate the sovereign://settings HTML page. */
static void handle_settings_page(WebKitURISchemeRequest *request) {
/* Read current settings state. */
/* Read current WebKit security settings state. */
int dev_extras = g_bridge.settings ?
webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0;
int file_access = g_bridge.settings ?
@@ -349,6 +398,54 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
int universal_access = g_bridge.settings ?
webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings) : 0;
/* Read browser preferences. */
const browser_settings_t *bs = settings_get();
const char *pos_str = "top";
switch (bs->tab_bar_position) {
case GTK_POS_TOP: pos_str = "top"; break;
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
case GTK_POS_LEFT: pos_str = "left"; break;
case GTK_POS_RIGHT: pos_str = "right"; break;
}
/* Build a nostr:npub1... URI for the QR code in the Identity section.
* Convert the hex pubkey to 32 bytes, then use nip021 to build the URI.
* If no identity is loaded, skip the QR code. */
char npub_uri[160] = "";
char qr_img_html[512] = "";
if (g_bridge.pubkey_hex[0] != '\0') {
unsigned char pubkey_bytes[32] = {0};
int valid = 1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
valid = 0;
break;
}
pubkey_bytes[i] = (unsigned char)byte;
}
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
sizeof(npub_uri)) == 0) {
/* URL-encode the nostr: URI for use in an <img src> query. */
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
snprintf(qr_img_html, sizeof(qr_img_html),
" <div style='text-align:center; margin: 12px 0;'>"
"<img src='sovereign://qr?text=%s&box_size=6&border=4' "
"alt='npub QR code' style='image-rendering: pixelated; "
"border: 4px solid #fff; border-radius: 4px;'>"
"<div class='info' style='margin-top: 6px;'>"
"Scan with a Nostr client to load this identity</div>"
"</div>\n",
enc);
g_free(enc);
}
}
/* HTML-escape the bootstrap relays for the <textarea>. Newlines are
* preserved (they're valid in textarea content), but <, >, & are
* escaped to prevent breaking the HTML. */
char *relays_escaped = g_markup_escape_text(bs->bootstrap_relays, -1);
/* Build the HTML page. */
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
@@ -373,6 +470,18 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" width: 20px; height: 20px; border-radius: 50%%; background: #e0e0e0;\n"
" transition: transform 0.2s; }\n"
" .toggle.on::after { transform: translateX(26px); }\n"
" .field { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .field input, .field select {\n"
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
" font-size: 13px; min-width: 200px; }\n"
" .field input:focus, .field select:focus { border-color: #ff4444; }\n"
" .save-btn { background: #ff4444; color: #fff; border: none;\n"
" border-radius: 4px; padding: 6px 16px; cursor: pointer;\n"
" font-family: monospace; font-size: 13px; margin-left: 8px; }\n"
" .save-btn:hover { background: #ff6666; }\n"
" .status { padding: 8px; margin: 10px 0; border-radius: 4px; display: none; }\n"
" .status.show { display: block; }\n"
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
@@ -388,6 +497,104 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" <div class='info-row'><span>Pubkey</span><span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>Method</span><span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span><span class='info'>%s</span></div>\n"
"%s"
"\n"
"<h2>Tabs</h2>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Restore session on startup</div>\n"
" <div class='setting-desc'>Reopen tabs from the previous session</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"restore_session\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>New tab URL</div>\n"
" <div class='setting-desc'>Page loaded when opening a new tab</div></div>\n"
" <div><input type='text' id='new_tab_url' value='%s'>\n"
" <button class='save-btn' onclick=\"save('new_tab_url')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Tab bar position</div>\n"
" <div class='setting-desc'>Where the tab strip appears</div></div>\n"
" <div><select id='tab_bar_position'>\n"
" <option value='top'%s>Top</option>\n"
" <option value='bottom'%s>Bottom</option>\n"
" <option value='left'%s>Left</option>\n"
" <option value='right'%s>Right</option>\n"
" </select>\n"
" <button class='save-btn' onclick=\"save('tab_bar_position')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Show tab close buttons</div>\n"
" <div class='setting-desc'>Per-tab close button in the tab strip</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"show_tab_close_buttons\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Middle-click to close tab</div>\n"
" <div class='setting-desc'>Middle-click on a tab closes it</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"middle_click_close\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Ctrl+Tab to switch tabs</div>\n"
" <div class='setting-desc'>Cycle tabs with Ctrl+Tab / Ctrl+Shift+Tab</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"ctrl_tab_switch\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Allow drag to reorder tabs</div>\n"
" <div class='setting-desc'>Drag tabs in the strip to reorder them</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"tab_drag_reorder\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Maximum tabs</div>\n"
" <div class='setting-desc'>Hard limit on simultaneous tabs</div></div>\n"
" <div><input type='number' id='max_tabs' value='%d' min='1' max='999'>\n"
" <button class='save-btn' onclick=\"save('max_tabs')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Agent Server</h2>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Enable agent server</div>\n"
" <div class='setting-desc'>MCP server for automation (takes effect on next launch)</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"agent_server_enabled\")'></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Agent server port</div>\n"
" <div class='setting-desc'>TCP port for the MCP server (next launch)</div></div>\n"
" <div><input type='number' id='agent_server_port' value='%d' min='1' max='65535'>\n"
" <button class='save-btn' onclick=\"save('agent_server_port')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Allowed origins</div>\n"
" <div class='setting-desc'>Comma-separated CORS origins (* for any)</div></div>\n"
" <div><input type='text' id='agent_allowed_origins' value='%s'>\n"
" <button class='save-btn' onclick=\"save('agent_allowed_origins')\">Save</button></div>\n"
"</div>\n"
"\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Login timeout (ms)</div>\n"
" <div class='setting-desc'>Wait for agent login before GTK dialog</div></div>\n"
" <div><input type='number' id='agent_login_timeout_ms' value='%d' min='0' max='300000'>\n"
" <button class='save-btn' onclick=\"save('agent_login_timeout_ms')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Bootstrap Relays</h2>\n"
"<p class='note'>Relays queried after login to fetch your profile (kind 0),\n"
"contacts (kind 3), and relay list (kind 10002). One wss:// URL per line.</p>\n"
"<div class='field'>\n"
" <div><div class='setting-name'>Relay URLs</div>\n"
" <div class='setting-desc'>One wss:// URL per line</div></div>\n"
" <div><textarea id='bootstrap_relays' rows='4' cols='40'>%s</textarea>\n"
" <button class='save-btn' onclick=\"save('bootstrap_relays')\">Save</button></div>\n"
"</div>\n"
"\n"
"<h2>Security</h2>\n"
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
@@ -427,31 +634,62 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
"<div id='status' class='status'></div>\n"
"\n"
"<script>\n"
" function showStatus(cls, msg) {\n"
" var s = document.getElementById('status');\n"
" s.className = 'status ' + cls + ' show';\n"
" s.textContent = msg;\n"
" }\n"
" function toggle(feature) {\n"
" fetch('sovereign://settings/set?feature=' + feature)\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" var s = document.getElementById('status');\n"
" if (d.error) {\n"
" s.className = 'status err show';\n"
" s.textContent = d.message;\n"
" showStatus('err', d.message);\n"
" } else {\n"
" s.className = 'status ok show';\n"
" s.textContent = d.feature + ': ' + (d.enabled ? 'ON' : 'OFF');\n"
" showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF'));\n"
" setTimeout(() => location.reload(), 500);\n"
" }\n"
" })\n"
" .catch(e => {\n"
" var s = document.getElementById('status');\n"
" s.className = 'status err show';\n"
" s.textContent = 'Error: ' + e.message;\n"
" });\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
" function save(key) {\n"
" var el = document.getElementById(key);\n"
" var val = el ? el.value : '';\n"
" fetch('sovereign://settings/set?key=' + encodeURIComponent(key)\n"
" + '&value=' + encodeURIComponent(val))\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" if (d.error) {\n"
" showStatus('err', d.message);\n"
" } else {\n"
" showStatus('ok', d.key + ' = ' + d.value);\n"
" if (d.reload) setTimeout(() => location.reload(), 500);\n"
" }\n"
" })\n"
" .catch(e => showStatus('err', 'Error: ' + e.message));\n"
" }\n"
"</script>\n"
"</body></html>\n",
g_bridge.pubkey_hex[0] ? g_bridge.pubkey_hex : "(none)",
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
qr_img_html,
bs->restore_session ? "on" : "off",
bs->new_tab_url,
strcmp(pos_str, "top") == 0 ? " selected" : "",
strcmp(pos_str, "bottom") == 0 ? " selected" : "",
strcmp(pos_str, "left") == 0 ? " selected" : "",
strcmp(pos_str, "right") == 0 ? " selected" : "",
bs->show_tab_close_buttons ? "on" : "off",
bs->middle_click_close ? "on" : "off",
bs->ctrl_tab_switch ? "on" : "off",
bs->tab_drag_reorder ? "on" : "off",
bs->max_tabs,
bs->agent_server_enabled ? "on" : "off",
bs->agent_server_port,
bs->agent_allowed_origins,
bs->agent_login_timeout_ms,
relays_escaped,
dev_extras ? "on" : "off",
file_access ? "on" : "off",
universal_access ? "on" : "off"
@@ -459,59 +697,405 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
respond_html(request, html);
g_free(html);
g_free(relays_escaped);
}
/* Handle sovereign://settings/set?feature=X — toggle a setting. */
/* Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y). */
static void handle_settings_set(WebKitURISchemeRequest *request,
const char *query) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "Settings not available");
browser_settings_t *bs = settings_get_mutable();
/* ── Toggle path: ?feature=X ──────────────────────────────────── */
char *feature = query_param(query, "feature");
if (feature != NULL) {
int new_state = 0;
int reload = 0;
if (strcmp(feature, "dev_extras") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
} else if (strcmp(feature, "file_access") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "universal_access") == 0) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "WebKit settings not available");
g_free(feature);
return;
}
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "restore_session") == 0) {
bs->restore_session = !bs->restore_session;
new_state = bs->restore_session;
settings_save();
} else if (strcmp(feature, "show_tab_close_buttons") == 0) {
bs->show_tab_close_buttons = !bs->show_tab_close_buttons;
new_state = bs->show_tab_close_buttons;
settings_save();
tab_manager_apply_settings();
} else if (strcmp(feature, "middle_click_close") == 0) {
bs->middle_click_close = !bs->middle_click_close;
new_state = bs->middle_click_close;
settings_save();
} else if (strcmp(feature, "ctrl_tab_switch") == 0) {
bs->ctrl_tab_switch = !bs->ctrl_tab_switch;
new_state = bs->ctrl_tab_switch;
settings_save();
} else if (strcmp(feature, "tab_drag_reorder") == 0) {
bs->tab_drag_reorder = !bs->tab_drag_reorder;
new_state = bs->tab_drag_reorder;
settings_save();
tab_manager_apply_settings();
} else if (strcmp(feature, "agent_server_enabled") == 0) {
bs->agent_server_enabled = !bs->agent_server_enabled;
new_state = bs->agent_server_enabled;
settings_save();
reload = 1;
} else {
respond_error_json(request, -1, "Unknown feature");
g_free(feature);
return;
}
g_print("[settings] %s: %s\n", feature, new_state ? "ON" : "OFF");
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "key", feature);
cJSON_AddBoolToObject(result, "value", new_state);
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(feature);
return;
}
/* Parse feature= from query string. */
const char *feat = strstr(query, "feature=");
if (feat == NULL) {
respond_error_json(request, -1, "Missing feature parameter");
/* ── Key/value path: ?key=X&value=Y ───────────────────────────── */
char *key = query_param(query, "key");
char *value = query_param(query, "value");
if (key == NULL || value == NULL) {
respond_error_json(request, -1, "Missing key or value parameter");
g_free(key);
g_free(value);
return;
}
feat += 8; /* skip "feature=" */
/* Extract feature name (up to & or end). */
char feature[64];
size_t i = 0;
while (feat[i] && feat[i] != '&' && i < sizeof(feature) - 1) {
feature[i] = feat[i];
i++;
}
feature[i] = '\0';
int reload = 0;
/* Toggle the setting. */
int new_state = 0;
if (strcmp(feature, "dev_extras") == 0) {
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
} else if (strcmp(feature, "file_access") == 0) {
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "universal_access") == 0) {
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
if (strcmp(key, "new_tab_url") == 0) {
snprintf(bs->new_tab_url, sizeof(bs->new_tab_url), "%s", value);
settings_save();
} else if (strcmp(key, "tab_bar_position") == 0) {
if (strcasecmp(value, "top") == 0) bs->tab_bar_position = GTK_POS_TOP;
else if (strcasecmp(value, "bottom") == 0) bs->tab_bar_position = GTK_POS_BOTTOM;
else if (strcasecmp(value, "left") == 0) bs->tab_bar_position = GTK_POS_LEFT;
else if (strcasecmp(value, "right") == 0) bs->tab_bar_position = GTK_POS_RIGHT;
else {
respond_error_json(request, -1, "Invalid position value");
g_free(key); g_free(value);
return;
}
settings_save();
tab_manager_apply_settings();
} else if (strcmp(key, "max_tabs") == 0) {
int v = atoi(value);
if (v < 1) v = 1;
bs->max_tabs = v;
settings_save();
} else if (strcmp(key, "agent_server_port") == 0) {
int v = atoi(value);
if (v < 1 || v > 65535) {
respond_error_json(request, -1, "Port out of range (1-65535)");
g_free(key); g_free(value);
return;
}
bs->agent_server_port = v;
settings_save();
reload = 1;
} else if (strcmp(key, "agent_allowed_origins") == 0) {
snprintf(bs->agent_allowed_origins,
sizeof(bs->agent_allowed_origins), "%s", value);
settings_save();
reload = 1;
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
int v = atoi(value);
if (v < 0) v = 0;
bs->agent_login_timeout_ms = v;
settings_save();
} else if (strcmp(key, "bootstrap_relays") == 0) {
/* The textarea value uses \n for newlines (URL-encoded as %0A
* by the browser, decoded by query_param). Store as-is. */
snprintf(bs->bootstrap_relays,
sizeof(bs->bootstrap_relays), "%s", value);
settings_save();
} else {
respond_error_json(request, -1, "Unknown feature");
respond_error_json(request, -1, "Unknown key");
g_free(key); g_free(value);
return;
}
g_print("[security] %s: %s\n", feature, new_state ? "ON" : "OFF");
g_print("[settings] %s = %s\n", key, value);
/* Return the new state. */
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "feature", feature);
cJSON_AddBoolToObject(result, "enabled", new_state);
cJSON_AddStringToObject(result, "key", key);
cJSON_AddStringToObject(result, "value", value);
if (reload) cJSON_AddBoolToObject(result, "reload", 1);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
g_free(key);
g_free(value);
}
/* ── Profile page (sovereign://profile) ──────────────────────────── */
/* Generate the sovereign://profile HTML page.
* Reads the user's kind 0 (profile) and kind 3 (contacts) events from
* the SQLite database and displays them. This is useful for verifying
* that the post-login relay fetch worked correctly. */
static void handle_profile_page(WebKitURISchemeRequest *request) {
if (g_bridge.pubkey_hex[0] == '\0') {
respond_html(request,
"<!DOCTYPE html><html><head><meta charset='UTF-8'>"
"<style>body{font-family:monospace;max-width:700px;margin:40px auto;"
"padding:20px;background:#1a1a1a;color:#e0e0e0;}"
"h1{color:#ff4444;border-bottom:2px solid #ff4444;padding-bottom:8px;}"
".note{color:#888;}</style></head><body>"
"<h1>sovereign browser — Profile</h1>"
"<p class='note'>No identity loaded. Log in to see your profile.</p>"
"</body></html>");
return;
}
/* Fetch kind 0 (profile metadata) and kind 3 (contacts) from SQLite. */
cJSON *kind0 = db_get_latest_event(g_bridge.pubkey_hex, 0);
cJSON *kind3 = db_get_latest_event(g_bridge.pubkey_hex, 3);
/* Parse kind 0 content — it's a JSON object with name, about, picture, etc. */
const char *name = "", *about = "", *picture = "", *display_name = "";
const char *website = "", *lud16 = "";
const char *kind0_created = "";
cJSON *meta = NULL;
if (kind0) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
meta = cJSON_Parse(content->valuestring);
if (meta) {
cJSON *item;
item = cJSON_GetObjectItemCaseSensitive(meta, "name");
if (cJSON_IsString(item)) name = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "display_name");
if (cJSON_IsString(item)) display_name = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "about");
if (cJSON_IsString(item)) about = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "picture");
if (cJSON_IsString(item)) picture = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "website");
if (cJSON_IsString(item)) website = item->valuestring;
item = cJSON_GetObjectItemCaseSensitive(meta, "lud16");
if (cJSON_IsString(item)) lud16 = item->valuestring;
}
}
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind0, "created_at");
if (cJSON_IsNumber(ca)) {
static char ca_buf[32];
snprintf(ca_buf, sizeof(ca_buf), "%ld", (long)ca->valuedouble);
kind0_created = ca_buf;
}
}
/* Parse kind 3 tags — p tags are followed pubkeys. */
int contact_count = 0;
const char *kind3_created = "";
if (kind3) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(kind3, "tags");
if (cJSON_IsArray(tags)) {
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "p") == 0) {
contact_count++;
}
}
}
cJSON *ca = cJSON_GetObjectItemCaseSensitive(kind3, "created_at");
if (cJSON_IsNumber(ca)) {
static char ca3_buf[32];
snprintf(ca3_buf, sizeof(ca3_buf), "%ld", (long)ca->valuedouble);
kind3_created = ca3_buf;
}
}
/* Count events in the database for this pubkey. */
int k0_count = db_count_events(g_bridge.pubkey_hex, 0);
int k3_count = db_count_events(g_bridge.pubkey_hex, 3);
int k10002_count = db_count_events(g_bridge.pubkey_hex, 10002);
/* Build the npub URI for the QR code. */
char npub_uri[160] = "";
char *qr_html = g_strdup("");
if (g_bridge.pubkey_hex[0]) {
unsigned char pubkey_bytes[32] = {0};
int valid = 1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(g_bridge.pubkey_hex + i * 2, "%02x", &byte) != 1) {
valid = 0; break;
}
pubkey_bytes[i] = (unsigned char)byte;
}
if (valid && nostr_build_uri_npub(pubkey_bytes, npub_uri,
sizeof(npub_uri)) == 0) {
char *enc = g_uri_escape_string(npub_uri, NULL, FALSE);
g_free(qr_html);
qr_html = g_strdup_printf(
"<div class='qr'><img src='sovereign://qr?text=%s"
"&box_size=6&border=4' alt='npub QR code'></div>\n", enc);
g_free(enc);
}
}
/* Escape strings for HTML. */
char *esc_name = g_markup_escape_text(name, -1);
char *esc_dname = g_markup_escape_text(display_name, -1);
char *esc_about = g_markup_escape_text(about, -1);
char *esc_picture = g_markup_escape_text(picture, -1);
char *esc_website = g_markup_escape_text(website, -1);
char *esc_lud16 = g_markup_escape_text(lud16, -1);
char *esc_npub = g_markup_escape_text(npub_uri, -1);
/* Build optional sub-sections. */
char *picture_html = picture[0] ?
g_strdup_printf("<img src='%s' alt='Profile picture' "
"style='max-width:120px;max-height:120px;border-radius:8px;"
"float:right;margin-left:16px;'>", esc_picture) :
g_strdup("");
char *about_html = about[0] ?
g_strdup_printf("<div class='info-row'><span>About</span>"
"<span class='info' style='max-width:400px;'>%s</span></div>\n",
esc_about) :
g_strdup("");
const char *k0_missing = kind0 ? "" :
"<p class='missing'>(no kind 0 event in database)</p>\n";
const char *k3_missing = kind3 ? "" :
"<p class='missing'>(no kind 3 event in database)</p>\n";
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Profile</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
" .info { color: #888; font-size: 12px; word-break: break-all; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
" .missing { color: #5a2a2a; font-style: italic; }\n"
" .qr { text-align: center; margin: 12px 0; }\n"
" .qr img { image-rendering: pixelated; border: 4px solid #fff; border-radius: 4px; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Profile</h1>\n"
"%s"
"<h2>Identity</h2>\n"
" <div class='info-row'><span>Pubkey</span>"
"<span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>npub URI</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Method</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span>"
"<span class='info'>%s</span></div>\n"
"%s"
"\n"
"<h2>Profile (kind 0)</h2>\n"
"%s"
" <div class='info-row'><span>Name</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Display Name</span>"
"<span class='info'>%s</span></div>\n"
"%s"
" <div class='info-row'><span>Website</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Lightning (lud16)</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Last updated</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<h2>Contacts (kind 3)</h2>\n"
"%s"
" <div class='info-row'><span>Following</span>"
"<span class='info'>%d pubkey(s)</span></div>\n"
" <div class='info-row'><span>Last updated</span>"
"<span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<h2>Relay List (kind 10002)</h2>\n"
" <div class='info-row'><span>Events in DB</span>"
"<span class='info'>%d</span></div>\n"
"\n"
"<p class='note'>This page shows data cached in the local SQLite database "
"from the bootstrap relay fetch. If fields show <span class='missing'>"
"(not found)</span>, the relay fetch hasn't completed or the user has no "
"events of that kind on the bootstrap relays.</p>\n"
"</body></html>\n",
picture_html,
g_bridge.pubkey_hex,
esc_npub,
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
qr_html,
k0_missing,
esc_name,
esc_dname,
about_html,
esc_website,
esc_lud16,
kind0_created[0] ? kind0_created : "(not found)",
k0_count,
k3_missing,
contact_count,
kind3_created[0] ? kind3_created : "(not found)",
k3_count,
k10002_count
);
respond_html(request, html);
g_free(html);
g_free(qr_html);
g_free(esc_name);
g_free(esc_dname);
g_free(esc_about);
g_free(esc_picture);
g_free(esc_website);
g_free(esc_lud16);
g_free(esc_npub);
g_free(picture_html);
g_free(about_html);
if (meta) cJSON_Delete(meta);
if (kind0) cJSON_Delete(kind0);
if (kind3) cJSON_Delete(kind3);
}
/* ── URI scheme callback ────────────────────────────────────────── */
@@ -528,6 +1112,49 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
return;
}
/* Route sovereign://qr?text=... — render a QR code as PNG.
* Optional query params:
* text (required) — the string to encode (URL-decoded).
* box_size (optional) — pixels per module (default 8).
* border (optional) — quiet-zone modules (default 4). */
if (strcmp(uri, "sovereign://qr") == 0 ||
strncmp(uri, "sovereign://qr?", 15) == 0) {
const char *q = strchr(uri, '?');
char *text = query_param(q ? q + 1 : "", "text");
if (text == NULL || text[0] == '\0') {
respond_error_json(request, -1, "Missing 'text' parameter");
g_free(text);
return;
}
char *bs_str = query_param(q ? q + 1 : "", "box_size");
char *bd_str = query_param(q ? q + 1 : "", "border");
int box_size = bs_str ? atoi(bs_str) : 8;
int border = bd_str ? atoi(bd_str) : 4;
if (box_size < 1) box_size = 1;
if (box_size > 32) box_size = 32;
if (border < 0) border = 0;
if (border > 16) border = 16;
GBytes *png = qr_render_png(text, box_size, border);
if (png == NULL) {
respond_error_json(request, -1,
"Failed to encode QR code (text too long?)");
} else {
respond_bytes(request, png, "image/png");
}
g_free(text);
g_free(bs_str);
g_free(bd_str);
return;
}
/* Route sovereign://profile — show the user's Nostr profile from
* cached kind 0/3 events in the SQLite database. */
if (strcmp(uri, "sovereign://profile") == 0) {
handle_profile_page(request);
return;
}
/* Route sovereign://settings requests. */
if (strcmp(uri, "sovereign://settings") == 0 ||
strncmp(uri, "sovereign://settings?", 21) == 0) {
+122
View File
@@ -0,0 +1,122 @@
/*
* qr.c QR code rendering wrapper around libqrencode
*
* Encodes text via QRcode_encodeString() and renders the module matrix
* into an RGBA pixel buffer, then wraps it in a GdkPixbuf. The pixbuf
* can be saved to PNG in memory (qr_render_png) or used directly by GTK
* widgets (qr_render_pixbuf).
*
* The QRcode->data[] layout is row-major, one byte per module, with the
* least-significant bit indicating a dark module (1 = dark, 0 = light).
* See <qrencode.h> for details.
*/
#include "qr.h"
#include <qrencode.h>
#include <string.h>
#include <stdlib.h>
/* ── Internal: render QRcode modules into a GdkPixbuf ──────────────── */
static GdkPixbuf *qr_code_to_pixbuf(const QRcode *code, int box_size,
int border) {
if (code == NULL || code->data == NULL || code->width <= 0) {
return NULL;
}
if (box_size < 1) box_size = 1;
if (border < 0) border = 0;
int modules = code->width; /* width == height for QR codes */
int dim = (modules + 2 * border) * box_size;
/* Allocate an RGBA buffer (gdk-pixbuf uses 8 bits/channel, 4 channels
* when an alpha channel is present; we use RGB to keep it simple and
* smaller). */
GdkPixbuf *pb = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE /* no alpha */,
8, dim, dim);
if (pb == NULL) return NULL;
int rowstride = gdk_pixbuf_get_rowstride(pb);
int n_channels = gdk_pixbuf_get_n_channels(pb);
guchar *pixels = gdk_pixbuf_get_pixels(pb);
/* Fill with white (the quiet zone + light modules). */
for (int y = 0; y < dim; y++) {
guchar *row = pixels + y * rowstride;
for (int x = 0; x < dim; x++) {
row[x * n_channels + 0] = 255; /* R */
row[x * n_channels + 1] = 255; /* G */
row[x * n_channels + 2] = 255; /* B */
}
}
/* Paint dark modules. QRcode->data is row-major: data[y * width + x],
* and bit 0 (LSB) of each byte is the module color (1 = dark). */
for (int my = 0; my < modules; my++) {
for (int mx = 0; mx < modules; mx++) {
unsigned char b = code->data[my * code->width + mx];
if (b & 1) {
/* Dark module — paint the box_size×box_size block. */
int px0 = (mx + border) * box_size;
int py0 = (my + border) * box_size;
for (int dy = 0; dy < box_size; dy++) {
guchar *row = pixels + (py0 + dy) * rowstride;
for (int dx = 0; dx < box_size; dx++) {
int idx = (px0 + dx) * n_channels;
row[idx + 0] = 0; /* R */
row[idx + 1] = 0; /* G */
row[idx + 2] = 0; /* B */
}
}
}
}
}
return pb;
}
/* ── Public API ────────────────────────────────────────────────────── */
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border) {
if (text == NULL || text[0] == '\0') return NULL;
/* version=0 → auto-select the smallest version that fits.
* level=QR_ECLEVEL_M medium error correction (good default for
* identity/URI codes that might be scanned at an angle or off-screen).
* hint=QR_MODE_8 treat input as 8-bit data (handles URIs, npubs, etc.).
* casesensitive=1 don't fold to uppercase. */
QRcode *code = QRcode_encodeString(text, 0, QR_ECLEVEL_M,
QR_MODE_8, 1);
if (code == NULL) {
return NULL;
}
GdkPixbuf *pb = qr_code_to_pixbuf(code, box_size, border);
QRcode_free(code);
return pb;
}
GBytes *qr_render_png(const char *text, int box_size, int border) {
GdkPixbuf *pb = qr_render_pixbuf(text, box_size, border);
if (pb == NULL) return NULL;
gchar *buffer = NULL;
gsize size = 0;
GError *err = NULL;
if (!gdk_pixbuf_save_to_bufferv(pb, &buffer, &size, "png",
NULL, NULL, &err)) {
g_printerr("[qr] Failed to encode PNG: %s\n",
err ? err->message : "(unknown)");
if (err) g_error_free(err);
g_object_unref(pb);
return NULL;
}
g_object_unref(pb);
/* g_bytes_new_take takes ownership of buffer (g_free). */
GBytes *bytes = g_bytes_new_take(buffer, size);
return bytes;
}
+49
View File
@@ -0,0 +1,49 @@
/*
* qr.h QR code rendering wrapper around libqrencode
*
* Provides a stable API so the rest of the codebase doesn't include
* <qrencode.h> directly. Renders a QR code for a given text string
* into either an in-memory PNG (GBytes) or a GdkPixbuf (for GTK widgets).
*
* Dependencies: libqrencode (LGPL-2.1+), gdk-pixbuf (via GTK).
*/
#ifndef QR_H
#define QR_H
#include <glib.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Render text as a QR code and return the PNG image in memory.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module (e.g. 8 for a chunky, scannable code).
* border : number of quiet-zone modules around the code (4 is standard).
*
* Returns a newly allocated GBytes containing PNG data, or NULL on failure.
* Caller must unref with g_bytes_unref().
*/
GBytes *qr_render_png(const char *text, int box_size, int border);
/*
* Render text as a QR code and return a GdkPixbuf.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module.
* border : number of quiet-zone modules around the code.
*
* Returns a newly allocated GdkPixbuf, or NULL on failure.
* Caller must unref with g_object_unref().
*/
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border);
#ifdef __cplusplus
}
#endif
#endif /* QR_H */
+139
View File
@@ -0,0 +1,139 @@
/*
* relay_fetch.c post-login bootstrap relay fetch for sovereign_browser
*
* Uses synchronous_query_relays_with_progress() from nostr_core_lib to
* query the user's kind 0/3/10002 events from the bootstrap relays, then
* stores them in the SQLite database via db_store_event().
*
* This runs in a background thread (relay_fetch_thread) so the browser
* is usable immediately after login. SQLite is opened with
* SQLITE_OPEN_FULLMUTEX so concurrent access from the main thread is safe.
*/
#include "relay_fetch.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <glib.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* Maximum number of relays to parse from the bootstrap_relays setting. */
#define MAX_RELAYS 32
/* Relay query timeout in seconds. */
#define RELAY_TIMEOUT_SECONDS 15
/* ── Public API ────────────────────────────────────────────────────── */
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] No pubkey, skipping bootstrap fetch\n");
return -1;
}
if (relay_urls == NULL || relay_count <= 0) {
g_printerr("[relay] No bootstrap relays configured\n");
return -1;
}
g_print("[relay] Fetching kind 0/3/10002 for %s from %d relay(s)...\n",
pubkey_hex, relay_count);
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002]} */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(filter, "kinds", kinds);
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
* from all relays. No NIP-42 auth needed for public events. */
int result_count = 0;
cJSON **results = synchronous_query_relays_with_progress(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
NULL, NULL, /* no progress callback */
0, NULL /* no NIP-42 auth */
);
cJSON_Delete(filter);
if (results == NULL || result_count <= 0) {
g_print("[relay] No events found (relays may be unreachable or "
"the user has no kind 0/3/10002 events)\n");
if (results) free(results);
return 0;
}
/* Store each event in the database. */
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
if (db_store_event(results[i]) == 0) {
stored++;
} else {
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
result_count);
}
cJSON_Delete(results[i]);
}
free(results);
g_print("[relay] Fetched %d event(s), stored %d in database\n",
result_count, stored);
return stored;
}
/* ── Background thread ─────────────────────────────────────────────── */
gpointer relay_fetch_thread(gpointer data) {
char *pubkey_hex = (char *)data;
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] Thread started with no pubkey\n");
return NULL;
}
/* Parse bootstrap relays from settings. */
const browser_settings_t *s = settings_get();
char *relay_buf = g_strdup(s->bootstrap_relays);
const char *relay_urls[MAX_RELAYS];
int relay_count = 0;
/* Tokenize the buffer in place. */
char *saveptr = NULL;
char *line = strtok_r(relay_buf, "\n\r", &saveptr);
while (line != NULL && relay_count < MAX_RELAYS) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
relay_urls[relay_count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
if (relay_count == 0) {
g_print("[relay] No valid bootstrap relay URLs in settings\n");
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;
}
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;
}
+45
View File
@@ -0,0 +1,45 @@
/*
* relay_fetch.h post-login bootstrap relay fetch for sovereign_browser
*
* After the user logs in, fetches their kind 0 (profile), kind 3 (contacts),
* and kind 10002 (relay list) events from the configured bootstrap relays
* and stores them in the SQLite database.
*/
#ifndef RELAY_FETCH_H
#define RELAY_FETCH_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays
* and store them in the SQLite database via db_store_event().
*
* pubkey_hex user's hex pubkey (64 chars, no 0x prefix)
* relay_urls array of relay URL strings (e.g. "wss://relay.damus.io")
* relay_count number of relays in the array
*
* Returns the number of events fetched and stored, or -1 on error.
*/
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count);
/*
* Background thread entry point for relay_fetch_bootstrap.
* Pass a pointer to a g_strdup'd pubkey_hex string (the thread frees it).
* Reads bootstrap relays from the global settings singleton.
*
* Usage: g_thread_new("relay-fetch", relay_fetch_thread, g_strdup(pubkey));
*/
gpointer relay_fetch_thread(gpointer data);
#ifdef __cplusplus
}
#endif
#endif /* RELAY_FETCH_H */
+37
View File
@@ -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);
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
#
# test_cli_flags.sh — smoke test for sovereign_browser CLI flags
#
# Tests --version, --help, and flag validation without launching the GUI
# (these flags exit before gtk_init). Run from the project root:
#
# ./tests/test_cli_flags.sh
#
# Exit code: 0 = all passed, 1 = any failed.
set -euo pipefail
BIN="./sovereign_browser"
PASS=0
FAIL=0
ok() {
echo " PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo " FAIL: $1"
FAIL=$((FAIL + 1))
}
echo "=== CLI Flags Smoke Test ==="
# --version should print version and exit 0
echo "[1] --version"
out=$("$BIN" --version 2>&1) || true
if echo "$out" | grep -q "^sovereign_browser v"; then
ok "--version prints version string"
else
fail "--version output unexpected: $out"
fi
# -V short form
echo "[2] -V"
out=$("$BIN" -V 2>&1) || true
if echo "$out" | grep -q "^sovereign_browser v"; then
ok "-V works as short form"
else
fail "-V output unexpected: $out"
fi
# --help should print usage and exit 0
echo "[3] --help"
out=$("$BIN" --help 2>&1) || true
if echo "$out" | grep -q "Usage: sovereign_browser"; then
ok "--help prints usage"
else
fail "--help output unexpected"
fi
# -h short form
echo "[4] -h"
out=$("$BIN" -h 2>&1) || true
if echo "$out" | grep -q "Usage: sovereign_browser"; then
ok "-h works as short form"
else
fail "-h output unexpected"
fi
# Unknown flag should exit non-zero
echo "[5] unknown flag"
if "$BIN" --nonexistent-flag 2>/dev/null; then
fail "unknown flag should exit non-zero"
else
ok "unknown flag exits non-zero"
fi
# --login-method with invalid method should exit non-zero
echo "[6] invalid login method"
if "$BIN" --login-method bogus 2>/dev/null; then
fail "invalid login method should exit non-zero"
else
ok "invalid login method exits non-zero"
fi
# --login-method local without --nsec/--privkey should exit non-zero
echo "[7] local method without credentials"
if "$BIN" --login-method local 2>/dev/null; then
fail "local method without credentials should exit non-zero"
else
ok "local method without credentials exits non-zero"
fi
# --port out of range should exit non-zero
echo "[8] invalid port"
if "$BIN" --port 99999 2>/dev/null; then
fail "invalid port should exit non-zero"
else
ok "invalid port exits non-zero"
fi
# --max-tabs 0 should exit non-zero
echo "[9] invalid max-tabs"
if "$BIN" --max-tabs 0 2>/dev/null; then
fail "max-tabs 0 should exit non-zero"
else
ok "max-tabs 0 exits non-zero"
fi
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
exit $((FAIL > 0 ? 1 : 0))