Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473e2e4a96 | ||
|
|
9a55fe15f7 | ||
|
|
5e26c502ee | ||
|
|
45aa46f6c4 | ||
|
|
480d758241 | ||
|
|
eb093ee16a | ||
|
|
618d52ff69 | ||
|
|
3d32c64b0a | ||
|
|
ca2907b357 | ||
|
|
0892a9c7e1 | ||
|
|
b3955e4231 | ||
|
|
3bacac4a42 | ||
|
|
fe1112f480 | ||
|
|
fa5dd93c6d | ||
|
|
7d91a186d4 | ||
|
|
6a8e51c812 | ||
|
|
12f8b45015 | ||
|
|
b90ea0bb11 | ||
|
|
5b949ec034 | ||
|
|
43ee97bc38 | ||
|
|
3ac4d4f9dc | ||
|
|
b111fffeaa | ||
|
|
7ca05baff1 | ||
|
|
6c1d309aac | ||
|
|
072915ef51 | ||
|
|
e482af8729 | ||
|
|
6f639cd419 | ||
|
|
6020df2e92 | ||
|
|
ae102323f6 | ||
|
|
b393308b2e | ||
|
|
0a1b117746 | ||
|
|
5150b7282a | ||
|
|
b57367de1c | ||
|
|
1c6f5b6366 | ||
|
|
21eff5c7c8 | ||
|
|
aa2ad9a6cb | ||
|
|
12054b9900 | ||
|
|
31195c4fe2 | ||
|
|
a4b4c52a91 | ||
|
|
02a92f7a26 | ||
|
|
a722a7cc67 | ||
|
|
d032cef0ab | ||
|
|
775bf27ab0 | ||
|
|
f01a345b7c | ||
|
|
f91ff74138 |
22
.gitignore
vendored
22
.gitignore
vendored
@@ -7,9 +7,31 @@ sovereign_browser
|
||||
*.dll
|
||||
build/
|
||||
|
||||
# release tarballs (created by increment_and_push.sh -r, cleaned up by trap)
|
||||
*.tar.gz
|
||||
|
||||
# compiled test binaries
|
||||
tests/test_bookmarks_tree
|
||||
tests/test_nostr_url
|
||||
|
||||
# editor / OS
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# vendored dependency (added as a subtree/checkout, not tracked here)
|
||||
nostr_core_lib/
|
||||
|
||||
# auto-generated embedded web content (built by embed_web_files.sh)
|
||||
src/embedded_web_content.c
|
||||
src/embedded_web_content.h
|
||||
|
||||
# binary media files for local-site test (too large for git)
|
||||
tests/local-site/assets/*.mp3
|
||||
tests/local-site/assets/*.m4a
|
||||
tests/local-site/assets/*.mp4
|
||||
tests/local-site/assets/*.webm
|
||||
tests/local-site/assets/*.ogg
|
||||
tests/local-site/assets/*.ogv
|
||||
tests/local-site/assets/*.wav
|
||||
tests/local-site/assets/*.avi
|
||||
tests/local-site/assets/*.mkv
|
||||
|
||||
108
.roo/mcp.json
Normal file
108
.roo/mcp.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"sovereign-browser": {
|
||||
"url": "http://localhost:17777/mcp",
|
||||
"type": "streamable-http",
|
||||
"alwaysAllow": [
|
||||
"login_status",
|
||||
"snapshot",
|
||||
"click",
|
||||
"click_at",
|
||||
"screenshot",
|
||||
"login",
|
||||
"logout",
|
||||
"switch_identity",
|
||||
"open",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"get_url",
|
||||
"get_title",
|
||||
"get_attr",
|
||||
"get_value",
|
||||
"get_count",
|
||||
"get_box",
|
||||
"get_styles",
|
||||
"is_visible",
|
||||
"is_enabled",
|
||||
"is_checked",
|
||||
"eval",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"focus",
|
||||
"close",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"wait",
|
||||
"wait_for",
|
||||
"screenshot_annotated",
|
||||
"pdf",
|
||||
"upload",
|
||||
"state_load",
|
||||
"get_text",
|
||||
"get_html",
|
||||
"scroll",
|
||||
"dblclick",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"scroll_into_view",
|
||||
"insert_text",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"drag",
|
||||
"close_all",
|
||||
"find_role",
|
||||
"find_label",
|
||||
"find_placeholder",
|
||||
"find_alt",
|
||||
"find_title",
|
||||
"find_testid",
|
||||
"find_first",
|
||||
"find_last",
|
||||
"find_nth",
|
||||
"wait_for_text",
|
||||
"wait_for_url",
|
||||
"wait_for_load",
|
||||
"batch",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_local_get",
|
||||
"storage_local_get_key",
|
||||
"storage_local_set",
|
||||
"storage_local_clear",
|
||||
"storage_session_get",
|
||||
"storage_session_get_key",
|
||||
"storage_session_set",
|
||||
"storage_session_clear",
|
||||
"mouse_move",
|
||||
"mouse_down",
|
||||
"mouse_up",
|
||||
"mouse_wheel",
|
||||
"clipboard_read",
|
||||
"clipboard_write",
|
||||
"clipboard_copy",
|
||||
"clipboard_paste",
|
||||
"set_viewport",
|
||||
"set_offline",
|
||||
"set_headers",
|
||||
"set_credentials",
|
||||
"set_media",
|
||||
"frame_switch",
|
||||
"frame_main",
|
||||
"dialog_accept",
|
||||
"dialog_dismiss",
|
||||
"dialog_status",
|
||||
"console",
|
||||
"errors",
|
||||
"highlight",
|
||||
"state_save"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
.roorules
11
.roorules
@@ -6,12 +6,15 @@
|
||||
|
||||
```bash
|
||||
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
|
||||
./browser.sh stop # kill running instance
|
||||
./browser.sh stop # kill ALL running instances
|
||||
./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
|
||||
./browser.sh status # list all running instances + MCP status
|
||||
./browser.sh log # tail the browser log
|
||||
./browser.sh mcp-url # print MCP endpoint URL(s) for running instance(s)
|
||||
```
|
||||
|
||||
**Multiple instances:** if the default MCP port (17777) is already in use by another instance, the browser automatically falls back to an OS-assigned port and records it in `/tmp/sovereign_browser_<pid>.port`. `browser.sh` reads these discovery files to find/stop/status every instance. Use `./browser.sh mcp-url` to discover the actual endpoint(s).
|
||||
|
||||
## 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:
|
||||
@@ -36,7 +39,7 @@ Alternatively, you can still log in at runtime via the MCP `login` tool with `{"
|
||||
|
||||
## MCP Server
|
||||
|
||||
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
|
||||
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP) for the first instance; subsequent instances get an OS-assigned port — use `./browser.sh mcp-url` to discover the actual endpoint(s)
|
||||
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
|
||||
- See `.roo/agents.md` for the full tool list and workflow
|
||||
|
||||
|
||||
23
Makefile
23
Makefile
@@ -5,7 +5,11 @@
|
||||
# libsecp256k1-dev, libssl-dev, libcurl4-openssl-dev
|
||||
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
||||
# Debug build: -g for debug symbols (stack traces in gdb/core dumps),
|
||||
# -O2 for optimization (runs at full speed). No ASAN (too slow for
|
||||
# daily use). To temporarily enable ASAN for crash investigation:
|
||||
# make clean && make CFLAGS="-std=c99 -Wall -Wextra -g -O0 -fsanitize=address" LDFLAGS="-fsanitize=address"
|
||||
CFLAGS ?= -std=c99 -Wall -Wextra -g -O2
|
||||
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
|
||||
CFLAGS += -I./nostr_core_lib
|
||||
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
|
||||
@@ -17,11 +21,24 @@ 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/cli.c src/qr.c src/db.c src/relay_fetch.c
|
||||
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c
|
||||
|
||||
$(BIN): $(SRC) $(NOSTR_LIB)
|
||||
# Web files embedded into the binary as C byte arrays.
|
||||
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
|
||||
|
||||
# Default goal: build the browser binary.
|
||||
$(BIN): $(SRC) $(NOSTR_LIB) src/embedded_web_content.c
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
|
||||
|
||||
# Auto-generated C byte arrays from www/. Regenerated whenever a web
|
||||
# file changes (or on first build). Both the .c and .h are produced by
|
||||
# a single invocation of embed_web_files.sh.
|
||||
src/embedded_web_content.c: $(WEB_FILES) embed_web_files.sh
|
||||
./embed_web_files.sh
|
||||
|
||||
src/embedded_web_content.h: src/embedded_web_content.c
|
||||
@true
|
||||
|
||||
$(NOSTR_LIB):
|
||||
@echo "Building nostr_core_lib..."
|
||||
cd nostr_core_lib && ./build.sh --nips=all
|
||||
|
||||
325
README.md
325
README.md
@@ -29,28 +29,125 @@ because trust moves to the layer where it belongs: your keys.
|
||||
4. **Deprecated web security, deliberately.** Same-origin policy, CORS, and
|
||||
certificate enforcement are stripped so pages (and the agent runtime to
|
||||
come) can freely call any endpoint — including FIPS mesh services — without
|
||||
the workarounds traditional browsers force on automators.
|
||||
the workarounds traditional browsers force on automators. Security moves
|
||||
up to the **Qube level** (isolation per VM), so the browser doesn't need
|
||||
the traditional in-browser security model.
|
||||
5. **Local file browsing.** Open and operate multipage websites directly from
|
||||
`file://` with no web server — cross-origin access between local files,
|
||||
`fetch()`/XHR, iframes, storage, media playback, all work. See
|
||||
[Local file browsing](#local-file-browsing) below.
|
||||
|
||||
## Non-goals (for now)
|
||||
## Nostr interaction policy
|
||||
|
||||
- Agent integration, multi-window agent hosts, didactyl hosting, and the
|
||||
broader "browser as agent runtime" vision are documented in
|
||||
[`docs/architecture.md`](docs/architecture.md) and the linked plans, but
|
||||
**not in scope for the first build**. First: a usable browser with basic
|
||||
Nostr signing. Then the rest.
|
||||
sovereign_browser interacts with Nostr via **discrete events only** — no
|
||||
continuous WebSocket subscriptions. The browser fetches events on demand
|
||||
(via `relay_fetch.c`) and publishes events on demand (via `settings_sync.c`,
|
||||
`agent_conversations.c`, `agent_skills.c`). There is no persistent
|
||||
subscription that receives live updates.
|
||||
|
||||
This is a deliberate architectural choice:
|
||||
|
||||
- **Simplicity** — No subscription lifecycle management, no reconnection
|
||||
logic, no event deduplication across reconnects.
|
||||
- **Predictability** — The user controls when data is fetched or published.
|
||||
No background traffic, no surprise events arriving.
|
||||
- **Resource efficiency** — No open WebSocket connections consuming memory
|
||||
and bandwidth while idle.
|
||||
|
||||
**Trade-off:** The browser does not receive live updates. If a conversation
|
||||
or skill is created in another app (e.g., the client web app), it won't
|
||||
appear in sovereign_browser until the user explicitly refreshes. UIs that
|
||||
need fresh data provide **Refresh buttons** that trigger a one-shot fetch.
|
||||
|
||||
### What this means for the chat page
|
||||
|
||||
The `sovereign://agents/chat` page differs from `~/lt/client/www/ai.html`
|
||||
in this respect:
|
||||
|
||||
- **ai.html** uses NDK with persistent subscriptions — conversations and
|
||||
skills appear live as they're published to relays.
|
||||
- **sovereign_browser** fetches conversations and skills on demand — the
|
||||
user clicks a Refresh button to pull new data from relays.
|
||||
|
||||
Conversations and skills are still stored as the same Nostr event kinds
|
||||
(30078 for conversations, 31123 for skills) with the same tags, so they're
|
||||
**compatible** across projects — just not live-synced.
|
||||
|
||||
## Current status
|
||||
|
||||
Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar
|
||||
per tab, Nostr login, and `window.nostr` injection. Verified loading
|
||||
`https://laantungir.net` cleanly. See
|
||||
[`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction
|
||||
report from the POC phase (and the Servo fallback exploration).
|
||||
Working browser with a broad feature set:
|
||||
|
||||
- **Multi-tab browsing** with session restore, tab drag/reorder, context menus
|
||||
- **Nostr login** (GTK dialog or CLI flags) with multiple methods: generate,
|
||||
local (nsec), seed (BIP-39), readonly (npub), NIP-46, n_signer hardware
|
||||
- **`window.nostr` injection** (NIP-07) into every page
|
||||
- **Agent MCP server** — 100 tools for browser automation via Streamable HTTP
|
||||
at `http://localhost:17777/mcp`
|
||||
- **Local file browsing** — open and operate multipage websites directly from
|
||||
`file://` with no web server (see below)
|
||||
- **Bookmarks, history, search** — built-in bookmark manager, browsing history,
|
||||
and search engine integration
|
||||
- **Keyboard shortcuts** — standard browser shortcuts (Ctrl+T, Ctrl+W, Ctrl+L,
|
||||
Ctrl+Tab, etc.)
|
||||
- **Per-user profiles** — separate identities and settings per profile
|
||||
- **Embedded web content** — `sovereign://` pages for settings, bookmarks,
|
||||
profile, and agent chat/config
|
||||
|
||||
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
|
||||
friction report from the POC phase.
|
||||
|
||||
## Install
|
||||
|
||||
One-command install on Debian 13 (trixie) or similar (x86_64, WebKitGTK 4.1):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.laantungir.net/laantungir/sovereign_browser/raw/branch/master/install.sh | bash
|
||||
```
|
||||
[loaded] https://laantungir.net/ -- title: Laan Tungir
|
||||
|
||||
This downloads and runs [`install.sh`](install.sh), which:
|
||||
|
||||
1. Installs runtime dependencies via `apt` (WebKitGTK 4.1, libsoup, curl, etc.)
|
||||
2. Installs **Tor** via `apt` (used for `.onion` routing)
|
||||
3. Builds and installs **FIPS** from source with `CAP_NET_ADMIN` (used for `.fips` mesh routing)
|
||||
4. Downloads the latest prebuilt `sovereign_browser` binary from the [Gitea releases](https://git.laantungir.net/laantungir/sovereign_browser/releases) (falls back to a source build if no binary is available)
|
||||
5. Installs a `sovereign-browser` wrapper command to `/usr/local/bin`
|
||||
|
||||
After install, start the browser with:
|
||||
|
||||
```bash
|
||||
sovereign-browser start --login-method generate
|
||||
```
|
||||
|
||||
### Install options
|
||||
|
||||
```bash
|
||||
# Non-root install to a user-writable prefix
|
||||
curl -fsSL <url> | INSTALL_PREFIX=$HOME/.local bash -s -- --yes
|
||||
|
||||
# Force a source build instead of downloading the prebuilt binary
|
||||
curl -fsSL <url> | bash -s -- --build-from-source
|
||||
|
||||
# Review the script before running it
|
||||
curl -fsSL <url> -o install.sh
|
||||
less install.sh
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
Run `bash install.sh --help` for the full option list.
|
||||
|
||||
### What the install requires
|
||||
|
||||
| Requirement | Why |
|
||||
|---|---|
|
||||
| Debian 13+ / Ubuntu 24.04+ (WebKitGTK 4.1) | The browser is dynamically linked against `libwebkit2gtk-4.1` |
|
||||
| x86_64 | Prebuilt binary is x86-64; arm64 needs `--build-from-source` |
|
||||
| `sudo` / root | For `apt install`, `setcap` on the FIPS binary, and writing to `/usr/local` |
|
||||
| Internet access | To download the binary, apt packages, and the FIPS source |
|
||||
|
||||
Tor and FIPS are **installed by default** (not optional). If either is absent at runtime the browser degrades gracefully — clearnet still works, `.onion`/`.fips` show an error page — but the installer sets them up so everything works out of the box.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
|
||||
@@ -58,9 +155,14 @@ Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev
|
||||
make
|
||||
./sovereign_browser [url]
|
||||
./browser.sh start [url] # build + launch (detached, won't hang terminal)
|
||||
```
|
||||
|
||||
> **Note:** Always use `./browser.sh` to start/stop the browser. Running
|
||||
> `./sovereign_browser` directly will hang the terminal because the GUI
|
||||
> process keeps stdout/stderr open. See `./browser.sh --help` or
|
||||
> [`.roo/agents.md`](.roo/agents.md) for details.
|
||||
|
||||
## Command-line flags
|
||||
|
||||
Run `./sovereign_browser --help` for the full list. Flags are parsed before
|
||||
@@ -122,21 +224,22 @@ credentials.
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Existing behavior — still works
|
||||
./sovereign_browser https://example.com
|
||||
# Basic usage (via browser.sh — recommended)
|
||||
./browser.sh start https://example.com
|
||||
|
||||
# Generate a fresh key, skip the login dialog, open a page
|
||||
./sovereign_browser --login-method generate --url https://example.com
|
||||
./browser.sh start --login-method generate --url https://example.com
|
||||
|
||||
# Local key from env, custom agent port
|
||||
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
|
||||
./browser.sh start --login-method local --nsec "$NSEC" --port 18888
|
||||
|
||||
# Read-only (npub), no agent server, two tabs
|
||||
./sovereign_browser --login-method readonly --npub npub1... \
|
||||
./browser.sh start --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
|
||||
# Open a local website (no web server needed)
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///path/to/website/index.html"
|
||||
```
|
||||
|
||||
## Architecture (summary)
|
||||
@@ -159,7 +262,7 @@ credentials.
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ WebKitGTK (system lib, ~35 MB) │
|
||||
│ Blink-grade renderer + V8 + libsoup network stack │
|
||||
│ WebCore renderer + JavaScriptCore + libsoup net │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -175,8 +278,9 @@ infrastructure, multiple uses:
|
||||
|
||||
| Scheme | Purpose | Status |
|
||||
|--------|---------|--------|
|
||||
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | Planned |
|
||||
| `sovereign://settings` | Browser-internal pages (like `chrome://settings`) | Planned |
|
||||
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | ✅ Working |
|
||||
| `sovereign://settings`, `sovereign://bookmarks`, `sovereign://profile` | Browser-internal pages (like `chrome://settings`) | ✅ Working |
|
||||
| `sovereign://agents/chat`, `sovereign://agents/config` | Agent chat and configuration pages | ✅ Working |
|
||||
| `fips://` / `*.fips` | Route to FIPS mesh nodes via TUN interface | Planned |
|
||||
| `nostr://` | Fetch Nostr events from relays, render as HTML | Planned |
|
||||
|
||||
@@ -241,79 +345,142 @@ is saved to `~/.sovereign_browser/session.txt`.
|
||||
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
|
||||
implementation plan.
|
||||
|
||||
### Agent tools
|
||||
### Local file browsing
|
||||
|
||||
The browser embeds a WebSocket server (using libsoup) that allows external
|
||||
AI agents to control the browser programmatically. Connect to
|
||||
`ws://localhost:17777/agent` and send JSON tool commands.
|
||||
sovereign_browser is designed to run websites directly from local files —
|
||||
open an `index.html` from its directory and navigate as if it were served
|
||||
over HTTP. No web server required. This is a deliberate design choice:
|
||||
security is moved up to the Qube level, so the browser doesn't need the
|
||||
traditional security restrictions (same-origin policy, CORS) that would
|
||||
block local-file web apps.
|
||||
|
||||
The server starts before the login dialog and remains available throughout
|
||||
the browser's lifecycle. The browser starts normally with the GTK login
|
||||
dialog — no blocking wait. An agent can log in at any time: while the dialog
|
||||
is showing, or after the browser is already running. If an agent logs in
|
||||
while the dialog is open, the agent's login takes priority.
|
||||
**What works on `file://` pages:**
|
||||
|
||||
**Login tools** (available before login):
|
||||
- **Relative links** between pages — `index.html` → `about.html`,
|
||||
`subdir/page.html`, `../index.html`, `../../../deep/page.html`
|
||||
- **Cross-origin access** — all `file://` URLs are treated as same-origin.
|
||||
Iframes can access their parent's DOM, `window.open()` popups are
|
||||
accessible, and `fetch()`/XHR work across directories.
|
||||
- **`fetch()` and `XMLHttpRequest`** — load local JSON, text, and other
|
||||
files via relative paths (returns `status=0`, the `file://` convention)
|
||||
- **External resources** — CSS stylesheets, JavaScript files, SVG images,
|
||||
favicons, `<picture>`/`srcset`, `<object>`/`<embed>`
|
||||
- **Video and audio playback** — MP4 video and M4A audio play correctly
|
||||
with full duration/dimension metadata
|
||||
- **All browser storage** — `localStorage`, `sessionStorage`, `cookies`,
|
||||
and `IndexedDB` all work fully (open, upgrade, put, get, count)
|
||||
- **`history.pushState`/`replaceState`** — SPA-style routing works on
|
||||
`file://` URLs
|
||||
- **Query strings and hash fragments** — preserved in `location.href`,
|
||||
accessible via `location.search`/`location.hash`
|
||||
- **URL-encoded filenames** — `encoded%20name.html` (file on disk:
|
||||
`encoded name.html`) loads correctly
|
||||
- **Form submissions** — GET forms append query strings to the target
|
||||
page; POST forms navigate (body silently dropped, no server)
|
||||
- **`window.open()`** — opens local pages in new tabs
|
||||
- **ES Modules** — dynamic `import()` works with local module files
|
||||
- **Web Workers and Shared Workers** — `new Worker()` and
|
||||
`new SharedWorker()` with local scripts enable background computation
|
||||
threads (including cross-tab shared workers)
|
||||
- **WebAssembly** — `WebAssembly.instantiate()` works for compiled code
|
||||
- **Cross-origin fetch to remote HTTPS** — `fetch('https://example.com')`
|
||||
from a `file://` page works with no CORS blocking (confirms the
|
||||
"deprecated web security" design goal)
|
||||
- **Cross-origin remote resources** — remote `<script src>`,
|
||||
`<link rel=stylesheet>`, and `<img>` from HTTPS CDNs all load correctly
|
||||
|
||||
- `login_status` — check if logged in
|
||||
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
|
||||
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
|
||||
(hardware signer via serial/unix/tcp/qrexec transport)
|
||||
- `logout` — clear signer and reset state
|
||||
- `switch_identity` — change identity (same params as login)
|
||||
**Known limitations (WebKit engine quirks, not bugs):**
|
||||
|
||||
**Browser tools** (available after login):
|
||||
- `fetch()` for missing files throws `Load failed` instead of returning an
|
||||
error Response (no HTTP status codes on `file://`)
|
||||
- `<video>`/`<audio>` `error` events don't fire for missing source files
|
||||
(valid media works perfectly)
|
||||
- Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs — use IndexedDB
|
||||
instead for local-file storage
|
||||
- Service Workers don't work on `file://` (W3C spec requires HTTP/HTTPS) —
|
||||
use Web Workers for background processing instead
|
||||
|
||||
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
|
||||
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
|
||||
`get_html`, `get_attr`, `eval` (run JavaScript)
|
||||
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
|
||||
`focus`, `close`
|
||||
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
|
||||
- Wait: `wait` (ms), `wait_for` (selector)
|
||||
|
||||
**Snapshot + ref workflow** (same pattern as agent-browser):
|
||||
**Test site:** A comprehensive multipage test website with 53 edge cases
|
||||
is in [`tests/local-site/`](tests/local-site/index.html). See
|
||||
[`tests/local-site/LOCAL-FILE-BROWSING-REPORT.md`](tests/local-site/LOCAL-FILE-BROWSING-REPORT.md)
|
||||
for the full test report.
|
||||
|
||||
```bash
|
||||
# Using websocat (cargo install websocat)
|
||||
# 1. Login
|
||||
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 2. Open a page
|
||||
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 3. Snapshot — get accessibility tree with refs
|
||||
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 4. Click by ref
|
||||
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
|
||||
# Open a local website
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///path/to/your/website/index.html"
|
||||
```
|
||||
|
||||
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
|
||||
status (running, port, client count, logged in).
|
||||
### Agent tools (MCP server)
|
||||
|
||||
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
|
||||
The browser embeds an MCP (Model Context Protocol) server using Streamable
|
||||
HTTP transport. External AI agents can control the browser programmatically
|
||||
via `http://localhost:17777/mcp`.
|
||||
|
||||
- `agent_server_enabled` (default: true)
|
||||
- `agent_server_port` (default: 17777)
|
||||
- `agent_allowed_origins` (default: `*`)
|
||||
- `agent_login_timeout_ms` (default: 30000)
|
||||
The server starts before the login dialog and remains available throughout
|
||||
the browser's lifecycle. An agent can log in at any time: while the dialog
|
||||
is showing, or after the browser is already running.
|
||||
|
||||
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
|
||||
implementation plan and tool comparison with agent-browser.
|
||||
**100 tools available**, including:
|
||||
|
||||
- **Login**: `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`
|
||||
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`,
|
||||
`find_alt`, `find_title`, `find_testid`
|
||||
- **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`
|
||||
- **Cookies & storage**: `cookies_get/set/clear`, `storage_local_*`, `storage_session_*`
|
||||
- **Frames**: `frame_switch`, `frame_main`
|
||||
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
|
||||
- **Debug**: `console`, `errors`, `highlight`
|
||||
- **Files**: `upload`, `pdf`
|
||||
|
||||
**Example workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Login (or use --login-method on the command line)
|
||||
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":"generate"}}}'
|
||||
|
||||
# 2. Open a page
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"https://example.com"}}}'
|
||||
|
||||
# 3. Snapshot — get accessibility tree with element refs
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"snapshot","arguments":{}}}'
|
||||
|
||||
# 4. Click by ref
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"click","arguments":{"ref":"@e2"}}}'
|
||||
```
|
||||
|
||||
See [`.roo/agents.md`](.roo/agents.md) for the full tool list and workflow
|
||||
guide, and [`plans/agent-tools.md`](plans/agent-tools.md) for the
|
||||
implementation plan.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. ✅ Base browser (WebKitGTK + C99, loads pages)
|
||||
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
|
||||
3. ✅ Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
|
||||
4. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
|
||||
5. ⏳ `window.nostr` injection (`sovereign://` URI scheme bridge)
|
||||
6. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
|
||||
7. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
|
||||
8. ⏳ `nostr://` content scheme
|
||||
9. ⏳ Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
|
||||
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
|
||||
3. ✅ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t, 6 methods)
|
||||
4. ✅ `window.nostr` injection (`sovereign://` URI scheme bridge)
|
||||
5. ✅ Agent MCP server (100 tools, Streamable HTTP at `http://localhost:17777/mcp`)
|
||||
6. ✅ Embedded web content (`sovereign://settings`, `bookmarks`, `profile`, `agents/chat`, `agents/config`)
|
||||
7. ✅ Bookmarks, history, search, keyboard shortcuts, per-user profiles
|
||||
8. ✅ Local file browsing (multipage websites from `file://` with no web server)
|
||||
9. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
|
||||
10. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
|
||||
11. ⏳ `nostr://` content scheme
|
||||
12. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
artifacts/mcp-after-restart.png
Normal file
BIN
artifacts/mcp-after-restart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-bookmark-toolbar-check.png
Normal file
BIN
artifacts/mcp-bookmark-toolbar-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-browser-full.png
Normal file
BIN
artifacts/mcp-browser-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-header-white-check.png
Normal file
BIN
artifacts/mcp-header-white-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
210
browser.sh
210
browser.sh
@@ -8,10 +8,16 @@
|
||||
#
|
||||
# Usage:
|
||||
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
|
||||
# ./browser.sh stop — kill any running instance
|
||||
# ./browser.sh stop — kill ALL running instances
|
||||
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
|
||||
# ./browser.sh status — check if running + MCP responsive
|
||||
# ./browser.sh status — list all running instances + MCP status
|
||||
# ./browser.sh log — tail the browser log
|
||||
# ./browser.sh mcp-url — print MCP endpoint URL(s) for running instance(s)
|
||||
#
|
||||
# Multiple instances: if the default MCP port (17777) is already in use by
|
||||
# another instance, the browser automatically falls back to an OS-assigned
|
||||
# port and records it in /tmp/sovereign_browser_<pid>.port. browser.sh
|
||||
# reads these discovery files to find/stop/status every instance.
|
||||
#
|
||||
# Extra args after start/restart are forwarded to the binary, e.g.:
|
||||
# ./browser.sh start --login-method generate --url https://example.com
|
||||
@@ -25,7 +31,37 @@ set -euo pipefail
|
||||
BIN="./sovereign_browser"
|
||||
LOG="/tmp/sovereign_browser.log"
|
||||
PID_FILE="/tmp/sovereign_browser.pid"
|
||||
PORT=17777
|
||||
DEFAULT_PORT=17777
|
||||
DISCOVERY_GLOB="/tmp/sovereign_browser_*.port"
|
||||
|
||||
# Read the port for a given PID from its discovery file. Falls back to
|
||||
# DEFAULT_PORT (17777) if no discovery file exists (e.g. older binary, or
|
||||
# the first instance that successfully bound the default port and wrote a
|
||||
# discovery file with 17777). Echoes the port to stdout.
|
||||
port_for_pid() {
|
||||
local pid="${1:-}"
|
||||
local file="/tmp/sovereign_browser_${pid}.port"
|
||||
if [[ -f "$file" ]]; then
|
||||
local p
|
||||
p=$(awk '{print $2}' "$file" 2>/dev/null | head -n1)
|
||||
if [[ -n "$p" ]]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "$DEFAULT_PORT"
|
||||
}
|
||||
|
||||
# Echo the port for the currently-running instance (per get_pid), or
|
||||
# DEFAULT_PORT if not running.
|
||||
current_port() {
|
||||
local pid
|
||||
if pid=$(get_pid 2>/dev/null); then
|
||||
port_for_pid "$pid"
|
||||
else
|
||||
echo "$DEFAULT_PORT"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a PID is actually the sovereign_browser process.
|
||||
# This safety guard prevents us from accidentally killing unrelated
|
||||
@@ -53,13 +89,24 @@ get_pid() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback: find the process LISTENing on the port.
|
||||
# Fallback 1: scan discovery files for a live sovereign_browser PID.
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
|
||||
echo "$dpid"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback 2: find the process LISTENing on the default 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 "")
|
||||
port_pid=$(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
|
||||
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
|
||||
echo "$port_pid"
|
||||
return 0
|
||||
@@ -67,13 +114,36 @@ get_pid() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Echo all live sovereign_browser PIDs (one per line), discovered via the
|
||||
# discovery files and the default-port listen socket. Used by cmd_stop to
|
||||
# kill every running instance.
|
||||
all_pids() {
|
||||
local pids=()
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
|
||||
pids+=("$dpid")
|
||||
fi
|
||||
done
|
||||
local pp
|
||||
for pp in $(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null || true); do
|
||||
if is_browser_pid "$pp"; then
|
||||
pids+=("$pp")
|
||||
fi
|
||||
done
|
||||
# Deduplicate.
|
||||
printf '%s\n' "${pids[@]}" 2>/dev/null | sort -u | grep -v '^$' || true
|
||||
}
|
||||
|
||||
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`).
|
||||
# Collect ALL running sovereign_browser PIDs — from the PID file,
|
||||
# discovery files, and the default-port listen socket. This kills
|
||||
# every instance when multiple are running on different ports.
|
||||
local candidates=()
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
while IFS= read -r pid; do
|
||||
@@ -82,7 +152,7 @@ cmd_stop() {
|
||||
fi
|
||||
while IFS= read -r pid; do
|
||||
[[ -n "$pid" ]] && candidates+=("$pid")
|
||||
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
done < <(all_pids)
|
||||
|
||||
# Deduplicate and kill only verified sovereign_browser PIDs.
|
||||
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
|
||||
@@ -96,7 +166,7 @@ cmd_stop() {
|
||||
# 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
|
||||
for pid in $(all_pids); do
|
||||
if is_browser_pid "$pid"; then
|
||||
echo "[browser] Force killing PID $pid..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
@@ -106,10 +176,21 @@ cmd_stop() {
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
# Clean up any leftover discovery files (the browser removes its own on
|
||||
# clean shutdown, but force-killed instances leave them behind).
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -z "$dpid" ]] || ! kill -0 "$dpid" 2>/dev/null; then
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# 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."
|
||||
# Final check: is anything still running?
|
||||
if [[ -n "$(all_pids)" ]]; then
|
||||
echo "[browser] Warning: some instances still running."
|
||||
else
|
||||
echo "[browser] Stopped."
|
||||
fi
|
||||
@@ -125,6 +206,45 @@ cmd_start() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Rendering environment detection ──────────────────────────────
|
||||
# WebKitGTK's compositor re-composites every frame. On systems with
|
||||
# GPU acceleration this is cheap (hardware-accelerated). But in
|
||||
# software-rendering environments (Qubes OS, headless VMs, no GPU,
|
||||
# llvmpipe/softpipe Mesa), the compositor pegs the CPU at ~100% on
|
||||
# content-heavy pages. In those cases we disable compositing mode and
|
||||
# the DMA-BUF renderer so WebKit uses the lighter non-composited path.
|
||||
#
|
||||
# On a normal desktop Linux with a real GPU, we leave these unset so
|
||||
# WebKit can use hardware-accelerated compositing.
|
||||
local software_rendering=0
|
||||
|
||||
# Check 1: LIBGL_ALWAYS_SOFTWARE is set (Qubes OS sets this unconditionally)
|
||||
if [[ "${LIBGL_ALWAYS_SOFTWARE:-}" == "1" ]]; then
|
||||
software_rendering=1
|
||||
fi
|
||||
|
||||
# Check 2: No DRM render device → no GPU available
|
||||
if [[ ! -e /dev/dri/renderD128 ]]; then
|
||||
software_rendering=1
|
||||
fi
|
||||
|
||||
# Check 3: EGL/GL renderer is a software rasterizer
|
||||
if command -v eglinfo >/dev/null 2>&1; then
|
||||
if eglinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
|
||||
software_rendering=1
|
||||
fi
|
||||
elif command -v glxinfo >/dev/null 2>&1; then
|
||||
if glxinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
|
||||
software_rendering=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$software_rendering" -eq 1 ]]; then
|
||||
echo "[browser] Software rendering detected — disabling WebKit compositor."
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
fi
|
||||
|
||||
# Build first
|
||||
echo "[browser] Building..."
|
||||
if ! make -s 2>/dev/null; then
|
||||
@@ -148,13 +268,18 @@ cmd_start() {
|
||||
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..."
|
||||
# Wait for the MCP server to be ready (up to 10 seconds). The actual
|
||||
# port may differ from DEFAULT_PORT if it was in use (the browser falls
|
||||
# back to an OS-assigned port and writes it to a discovery file), so
|
||||
# read the port from the discovery file each iteration.
|
||||
echo "[browser] Waiting for MCP server (PID $pid)..."
|
||||
local mcp_port=""
|
||||
for i in $(seq 1 100); do
|
||||
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
|
||||
mcp_port=$(port_for_pid "$pid")
|
||||
if curl -s -o /dev/null -X POST "http://localhost:$mcp_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)."
|
||||
echo "[browser] MCP server is ready (PID $pid, port $mcp_port)."
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
@@ -172,22 +297,51 @@ cmd_restart() {
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local pids
|
||||
pids=$(all_pids)
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "[browser] Not running."
|
||||
return 0
|
||||
fi
|
||||
local count
|
||||
count=$(echo "$pids" | wc -l)
|
||||
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" \
|
||||
for pid in $pids; do
|
||||
local p
|
||||
p=$(port_for_pid "$pid")
|
||||
echo "[browser] Running (PID $pid, port $p)."
|
||||
if curl -s -o /dev/null -X POST "http://localhost:$p/mcp" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
|
||||
echo "[browser] MCP server: responsive"
|
||||
echo "[browser] MCP server: responsive"
|
||||
else
|
||||
echo "[browser] MCP server: NOT responding"
|
||||
echo "[browser] MCP server: NOT responding"
|
||||
fi
|
||||
else
|
||||
echo "[browser] Not running."
|
||||
done
|
||||
if [[ "$count" -gt 1 ]]; then
|
||||
echo "[browser] $count instances running."
|
||||
fi
|
||||
}
|
||||
|
||||
# Print the MCP endpoint URL for the running instance (or all instances
|
||||
# if multiple are running). Useful for external clients that need to
|
||||
# discover the port programmatically:
|
||||
# URL=$(./browser.sh mcp-url)
|
||||
cmd_mcp_url() {
|
||||
local pids
|
||||
pids=$(all_pids)
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "[browser] Not running." >&2
|
||||
return 1
|
||||
fi
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
local p
|
||||
p=$(port_for_pid "$pid")
|
||||
echo "http://localhost:$p/mcp"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_log() {
|
||||
if [[ -f "$LOG" ]]; then
|
||||
tail -50 "$LOG"
|
||||
@@ -202,9 +356,11 @@ case "${1:-}" in
|
||||
restart) shift; cmd_restart "$@" ;;
|
||||
status) cmd_status ;;
|
||||
log) cmd_log ;;
|
||||
mcp-url) cmd_mcp_url ;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
|
||||
echo "Usage: $0 {start|stop|restart|status|log|mcp-url} [ARGS...]" >&2
|
||||
echo " start/restart forward extra args to the binary." >&2
|
||||
echo " mcp-url prints the MCP endpoint URL(s) for running instance(s)." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
132
embed_web_files.sh
Executable file
132
embed_web_files.sh
Executable file
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# embed_web_files.sh — Convert www/ web files into C byte arrays
|
||||
#
|
||||
# Scans www/**/*.html, www/**/*.css, www/**/*.js and generates
|
||||
# src/embedded_web_content.c + src/embedded_web_content.h with the
|
||||
# file contents as C byte arrays. The sovereign:// URI scheme handler
|
||||
# looks up files by path via get_embedded_file().
|
||||
#
|
||||
# Adapted from ~/lt/c-relay/embed_web_files.sh.
|
||||
|
||||
set -e
|
||||
|
||||
echo "[embed] Embedding web files into C byte arrays..."
|
||||
|
||||
# Output directory for generated files
|
||||
OUTPUT_DIR="src"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Function to convert a file to a C byte array
|
||||
file_to_c_array() {
|
||||
local input_file="$1"
|
||||
local array_name="$2"
|
||||
local output_file="$3"
|
||||
|
||||
# Get file size
|
||||
local file_size=$(stat -c%s "$input_file" 2>/dev/null || stat -f%z "$input_file" 2>/dev/null || echo "0")
|
||||
|
||||
echo "// Auto-generated from $input_file" >> "$output_file"
|
||||
echo "static const unsigned char ${array_name}_data[] = {" >> "$output_file"
|
||||
|
||||
# Convert file to hex bytes
|
||||
hexdump -v -e '1/1 "0x%02x,"' "$input_file" >> "$output_file"
|
||||
|
||||
echo "};" >> "$output_file"
|
||||
echo "static const size_t ${array_name}_size = $file_size;" >> "$output_file"
|
||||
echo "" >> "$output_file"
|
||||
}
|
||||
|
||||
# Generate the header file
|
||||
HEADER_FILE="$OUTPUT_DIR/embedded_web_content.h"
|
||||
cat > "$HEADER_FILE" <<'EOF'
|
||||
// Auto-generated embedded web content header
|
||||
// Do not edit manually - generated by embed_web_files.sh
|
||||
|
||||
#ifndef EMBEDDED_WEB_CONTENT_H
|
||||
#define EMBEDDED_WEB_CONTENT_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct {
|
||||
const char *path; /* e.g. "agents/chat.html" */
|
||||
const unsigned char *data;
|
||||
size_t size;
|
||||
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
|
||||
} embedded_file_t;
|
||||
|
||||
/*
|
||||
* Look up an embedded file by its path (e.g. "agents/chat.html").
|
||||
* Returns NULL if not found.
|
||||
*/
|
||||
const embedded_file_t *get_embedded_file(const char *path);
|
||||
|
||||
#endif /* EMBEDDED_WEB_CONTENT_H */
|
||||
EOF
|
||||
|
||||
# Generate the C file
|
||||
SOURCE_FILE="$OUTPUT_DIR/embedded_web_content.c"
|
||||
cat > "$SOURCE_FILE" <<'EOF'
|
||||
// Auto-generated embedded web content
|
||||
// Do not edit manually - generated by embed_web_files.sh
|
||||
|
||||
#include "embedded_web_content.h"
|
||||
#include <string.h>
|
||||
|
||||
EOF
|
||||
|
||||
# Process each web file and build the lookup table
|
||||
declare -a file_entries
|
||||
|
||||
for file in $(find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.mjs' \) | sort); do
|
||||
if [ -f "$file" ]; then
|
||||
# Get path relative to www/
|
||||
rel_path="${file#www/}"
|
||||
|
||||
# Create C identifier from path (replace non-alphanumeric with _)
|
||||
c_name=$(echo "$rel_path" | sed 's/[^a-zA-Z0-9]/_/g' | sed 's/^_//')
|
||||
|
||||
# Determine content type
|
||||
case "$file" in
|
||||
*.html) content_type="text/html" ;;
|
||||
*.css) content_type="text/css" ;;
|
||||
*.js) content_type="text/javascript" ;;
|
||||
*.mjs) content_type="text/javascript" ;;
|
||||
*) content_type="text/plain" ;;
|
||||
esac
|
||||
|
||||
echo "[embed] $rel_path -> ${c_name} ($content_type)"
|
||||
|
||||
# Generate the byte array
|
||||
file_to_c_array "$file" "$c_name" "$SOURCE_FILE"
|
||||
|
||||
# Remember for the lookup table (no outer braces — the echo loop
|
||||
# below wraps each entry in a single set of braces)
|
||||
file_entries+=("\"$rel_path\", ${c_name}_data, ${c_name}_size, \"$content_type\"")
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate the lookup table
|
||||
echo "// File mapping" >> "$SOURCE_FILE"
|
||||
echo "static const embedded_file_t embedded_files[] = {" >> "$SOURCE_FILE"
|
||||
for entry in "${file_entries[@]}"; do
|
||||
echo " { $entry }," >> "$SOURCE_FILE"
|
||||
done
|
||||
echo " {NULL, NULL, 0, NULL} // Sentinel" >> "$SOURCE_FILE"
|
||||
echo "};" >> "$SOURCE_FILE"
|
||||
echo "" >> "$SOURCE_FILE"
|
||||
|
||||
# Generate the lookup function
|
||||
cat >> "$SOURCE_FILE" <<'EOF'
|
||||
const embedded_file_t *get_embedded_file(const char *path) {
|
||||
if (!path) return NULL;
|
||||
for (int i = 0; embedded_files[i].path != NULL; i++) {
|
||||
if (strcmp(path, embedded_files[i].path) == 0) {
|
||||
return &embedded_files[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "[embed] Generated $HEADER_FILE and $SOURCE_FILE"
|
||||
echo "[embed] Embedded ${#file_entries[@]} files"
|
||||
@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
# --- Cleanup trap -------------------------------------------------------
|
||||
# Any temp files registered here are removed on exit, including when the
|
||||
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
|
||||
# leftover sovereign_browser-*.tar.gz files from accumulating in the
|
||||
# project root when a release run is interrupted.
|
||||
CLEANUP_FILES=()
|
||||
cleanup_on_exit() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
|
||||
done
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
|
||||
# Gitea repo for release uploads (derived from the origin remote).
|
||||
@@ -234,8 +250,8 @@ git_commit_and_push() {
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
git push origin "$CURRENT_BRANCH"
|
||||
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
|
||||
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
|
||||
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
|
||||
}
|
||||
|
||||
# --- Release mode: build + Gitea release --------------------------------
|
||||
@@ -270,6 +286,10 @@ create_source_tarball() {
|
||||
--exclude='*.log' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude='nostr_core_lib' \
|
||||
--exclude='tests/local-site/assets/*.mp4' \
|
||||
--exclude='tests/local-site/assets/*.m4a' \
|
||||
--exclude='tests/local-site/assets/*.webm' \
|
||||
--exclude='tests/local-site/assets/*.ogg' \
|
||||
. > /dev/null 2>&1; then
|
||||
echo "$tarball_name"
|
||||
else
|
||||
@@ -354,16 +374,24 @@ if [[ "$RELEASE_MODE" == true ]]; then
|
||||
|
||||
local_tarball=""
|
||||
local_tarball=$(create_source_tarball || true)
|
||||
# Register the tarball for cleanup so it's removed on exit even if a
|
||||
# later step fails or the script is interrupted. The trap also catches
|
||||
# the case where create_gitea_release aborts via `set -e`.
|
||||
register_cleanup "$local_tarball"
|
||||
|
||||
print_status "Creating Gitea release for $NEW_VERSION..."
|
||||
release_id=""
|
||||
release_id=$(create_gitea_release || true)
|
||||
|
||||
if [[ -n "$release_id" ]]; then
|
||||
print_status "Uploading release assets (binary + tarball)..."
|
||||
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
|
||||
else
|
||||
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
|
||||
fi
|
||||
|
||||
# Clean up the tarball (the binary is gitignored already).
|
||||
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
|
||||
# The tarball is removed by the cleanup_on_exit trap registered above,
|
||||
# so no manual rm is needed here. The binary is gitignored already.
|
||||
|
||||
print_success "Release flow completed: $NEW_VERSION"
|
||||
else
|
||||
|
||||
619
install.sh
Executable file
619
install.sh
Executable file
@@ -0,0 +1,619 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# install.sh — curl | bash installer for sovereign_browser
|
||||
#
|
||||
# Installs:
|
||||
# * apt runtime deps (WebKitGTK 4.1, libsoup-3, tor, build toolchain)
|
||||
# * FIPS prebuilt binary from GitHub releases (source-build fallback via
|
||||
# --build-fips-from-source) with CAP_NET_ADMIN
|
||||
# * sovereign_browser prebuilt binary from Gitea releases (source-build fallback)
|
||||
# * a `sovereign-browser` wrapper that detaches the GUI
|
||||
#
|
||||
# Tor and FIPS are installed by default — they are NOT optional.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL <url> | bash
|
||||
# curl -fsSL <url> | bash -s -- --build-from-source
|
||||
# ./install.sh [options]
|
||||
|
||||
# --- Config --------------------------------------------------------------
|
||||
|
||||
GITEA_API="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
|
||||
GITEA_DOWNLOAD_BASE="https://git.laantungir.net/laantungir/sovereign_browser/releases/download"
|
||||
SB_REPO="https://git.laantungir.net/laantungir/sovereign_browser.git"
|
||||
FIPS_GITHUB_API="https://api.github.com/repos/jmcorgan/fips/releases/latest"
|
||||
FIPS_REPO="https://github.com/jmcorgan/fips.git"
|
||||
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}"
|
||||
BIN_NAME="sovereign_browser"
|
||||
|
||||
# Runtime apt packages (non-dev). git/build-essential/pkg-config are included
|
||||
# because they're needed for the FIPS source build and the source-build fallback.
|
||||
APT_PACKAGES=(
|
||||
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode4 libsqlite3-0
|
||||
libsecp256k1-2 libssl3t64 libcurl4t64 zlib1g
|
||||
tor git build-essential pkg-config
|
||||
libcap2-bin
|
||||
)
|
||||
# libclang-dev is only needed when building FIPS from source (bindgen).
|
||||
APT_PACKAGES_FIPS_SOURCE=(libclang-dev)
|
||||
|
||||
# --- Output helpers ------------------------------------------------------
|
||||
|
||||
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' BLUE='' NC=''
|
||||
fi
|
||||
|
||||
print_info() { echo -e "${BLUE}[INFO]${NC} $*" >&2; }
|
||||
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
die() { print_error "$*"; exit 1; }
|
||||
|
||||
# --- Privilege helper ----------------------------------------------------
|
||||
# Returns "sudo" if the target path is not user-writable, else "".
|
||||
sudo_for() {
|
||||
local target="$1"
|
||||
if [[ -w "$target" ]]; then
|
||||
echo ""
|
||||
else
|
||||
command -v sudo >/dev/null 2>&1 || die "sudo is required to write to $target but is not available."
|
||||
echo "sudo"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Usage ---------------------------------------------------------------
|
||||
|
||||
show_usage() {
|
||||
cat <<'EOF'
|
||||
sovereign_browser installer
|
||||
|
||||
Usage: curl -fsSL <url> | bash
|
||||
or: curl -fsSL <url> | bash -s -- --build-from-source
|
||||
or: ./install.sh [options]
|
||||
|
||||
Options:
|
||||
--build-from-source Build sovereign_browser from source instead of
|
||||
downloading the prebuilt binary
|
||||
--build-fips-from-source Build FIPS from source (cargo) instead of
|
||||
downloading the prebuilt binary. Slower; needs
|
||||
libclang-dev (auto-installed).
|
||||
--prefix <dir> Install prefix (default: /usr/local)
|
||||
--yes Skip confirmation prompts
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Args ----------------------------------------------------------------
|
||||
|
||||
BUILD_FROM_SOURCE=false
|
||||
BUILD_FIPS_FROM_SOURCE=false
|
||||
ASSUME_YES=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--build-from-source) BUILD_FROM_SOURCE=true; shift ;;
|
||||
--build-fips-from-source) BUILD_FIPS_FROM_SOURCE=true; shift ;;
|
||||
--prefix) INSTALL_PREFIX="${2:-}"; shift 2 ;;
|
||||
--yes|-y) ASSUME_YES=true; shift ;;
|
||||
-h|--help) show_usage; exit 0 ;;
|
||||
*) die "Unknown option: $1 (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Platform detection --------------------------------------------------
|
||||
|
||||
detect_platform() {
|
||||
print_info "Detecting platform..."
|
||||
local os arch
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
[[ "$os" == "Linux" ]] || die "This installer only supports Linux (got: $os)."
|
||||
|
||||
if [[ "$arch" != "x86_64" ]]; then
|
||||
if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
|
||||
die "arm64 is not supported for the prebuilt binary. Re-run with --build-from-source (arm64 source builds are untested)."
|
||||
fi
|
||||
die "Unsupported architecture: $arch (x86_64 only)."
|
||||
fi
|
||||
|
||||
local distro_id="" version_id=""
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
distro_id="${ID:-}"
|
||||
version_id="${VERSION_ID:-}"
|
||||
fi
|
||||
|
||||
local distro_ok=false
|
||||
if [[ "$distro_id" == "debian" ]]; then
|
||||
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 13)}'; then
|
||||
distro_ok=true
|
||||
fi
|
||||
elif [[ "$distro_id" == "ubuntu" ]]; then
|
||||
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 24.04)}'; then
|
||||
distro_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if $distro_ok; then
|
||||
print_info "Distro: $distro_id $version_id (supported)."
|
||||
else
|
||||
print_warning "Distro '$distro_id $version_id' is not explicitly supported."
|
||||
print_warning "WebKitGTK 4.1 runtime libs are required. If apt cannot find libwebkit2gtk-4.1-0, use Debian 13 (trixie)+ or Ubuntu 24.04+."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- apt dependencies ----------------------------------------------------
|
||||
|
||||
install_deps() {
|
||||
print_info "Installing apt dependencies (runtime libs, tor, build toolchain)..."
|
||||
if ! command -v sudo >/dev/null 2>&1 && [[ $EUID -ne 0 ]]; then
|
||||
die "sudo is required for apt-get but is not available (not running as root)."
|
||||
fi
|
||||
|
||||
local SUDO=""
|
||||
[[ $EUID -ne 0 ]] && SUDO="sudo"
|
||||
|
||||
$SUDO apt-get update
|
||||
if ! $SUDO apt-get install -y "${APT_PACKAGES[@]}"; then
|
||||
print_error "apt-get install failed. One or more packages could not be found."
|
||||
print_error "This usually means your distro is too old and lacks WebKitGTK 4.1."
|
||||
print_error "Use Debian 13 (trixie) or Ubuntu 24.04+, then re-run this installer."
|
||||
exit 1
|
||||
fi
|
||||
print_success "Dependencies installed."
|
||||
}
|
||||
|
||||
# --- FIPS ----------------------------------------------------------------
|
||||
#
|
||||
# FIPS is a Rust project (https://github.com/jmcorgan/fips). It publishes
|
||||
# prebuilt release tarballs on GitHub Releases containing the `fips` binary
|
||||
# plus tools (fipsctl, fipstop) and systemd units. We download the prebuilt
|
||||
# x86_64 Linux tarball by default — much faster than a cargo build.
|
||||
#
|
||||
# With --build-fips-from-source, we instead clone the repo and run
|
||||
# `cargo build --release` (requires Rust 1.94.1+ via rustup, and libclang-dev
|
||||
# for the rustables/bindgen nftables bindings).
|
||||
|
||||
# Download the prebuilt FIPS tarball from GitHub Releases and extract the
|
||||
# `fips` binary. Outputs a stable binary path on stdout (the file is copied
|
||||
# to a caller-managed temp file so it survives after this function returns).
|
||||
# Returns non-zero on failure so the caller can fall back to a source build.
|
||||
download_fips_prebuilt() {
|
||||
print_info "Querying GitHub for latest FIPS release..."
|
||||
local api_json tag asset_url
|
||||
if ! api_json="$(curl -fsSL "$FIPS_GITHUB_API" 2>/dev/null)"; then
|
||||
print_warning "Could not reach FIPS GitHub releases API."
|
||||
return 1
|
||||
fi
|
||||
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
print_warning "Could not parse tag_name from FIPS GitHub release JSON."
|
||||
return 1
|
||||
fi
|
||||
# Find the linux-x86_64 tarball asset URL.
|
||||
asset_url="$(printf '%s' "$api_json" | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*linux-x86_64\.tar\.gz"' | head -1 | sed -E 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$asset_url" ]]; then
|
||||
print_warning "No linux-x86_64 tarball found in FIPS release $tag."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use a temp dir for download + extraction. We do NOT use a RETURN trap
|
||||
# here because the caller needs to copy the binary out after this
|
||||
# function returns — a RETURN trap would delete the file first. Instead
|
||||
# we copy the binary to a stable temp file and clean up the extraction
|
||||
# dir ourselves before returning.
|
||||
local tmp fips_bin stable
|
||||
tmp="$(mktemp -d -t fips_dl.XXXXXX)"
|
||||
print_info "Downloading FIPS prebuilt binary: $asset_url"
|
||||
if ! curl -fsSL -o "$tmp/fips.tar.gz" "$asset_url"; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball download failed."
|
||||
return 1
|
||||
fi
|
||||
if ! tar -xzf "$tmp/fips.tar.gz" -C "$tmp" 2>/dev/null; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball extraction failed."
|
||||
return 1
|
||||
fi
|
||||
# The tarball extracts to fips-<ver>-linux-x86_64/fips
|
||||
fips_bin="$(find "$tmp" -type f -name fips 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$fips_bin" ]] || ! file "$fips_bin" 2>/dev/null | grep -qi ELF; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball did not contain an ELF 'fips' binary."
|
||||
return 1
|
||||
fi
|
||||
# Copy to a stable path so the caller can access it after we clean up
|
||||
# the extraction dir. mktemp gives a file the caller can rm later.
|
||||
stable="$(mktemp -t fips_binary.XXXXXX)"
|
||||
cp -f "$fips_bin" "$stable"
|
||||
chmod +x "$stable"
|
||||
rm -rf "$tmp"
|
||||
printf '%s' "$stable"
|
||||
}
|
||||
|
||||
# --- Rust toolchain (only for --build-fips-from-source) ------------------
|
||||
|
||||
FIPS_MIN_RUST_MAJOR=1
|
||||
FIPS_MIN_RUST_MINOR=94
|
||||
|
||||
rust_version_ok() {
|
||||
command -v cargo >/dev/null 2>&1 || return 1
|
||||
local ver
|
||||
ver="$(cargo --version 2>/dev/null | grep -oE 'cargo [0-9]+\.[0-9]+\.' | head -1 | sed 's/cargo //; s/\.$//')" || return 1
|
||||
[[ -z "$ver" ]] && return 1
|
||||
local major minor
|
||||
major="${ver%%.*}"
|
||||
minor="${ver#*.}"
|
||||
[[ "$major" -gt "$FIPS_MIN_RUST_MAJOR" ]] && return 0
|
||||
[[ "$major" -eq "$FIPS_MIN_RUST_MAJOR" && "$minor" -ge "$FIPS_MIN_RUST_MINOR" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_rust() {
|
||||
if rust_version_ok; then
|
||||
print_info "Rust toolchain OK ($(cargo --version))."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
print_warning "Installed cargo ($(cargo --version)) is older than FIPS requires (1.94.1+). Installing a newer toolchain via rustup."
|
||||
else
|
||||
print_info "No cargo found. Installing Rust toolchain via rustup."
|
||||
fi
|
||||
|
||||
local rustup_init
|
||||
rustup_init="$(mktemp -t rustup-init.XXXXXX)"
|
||||
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$rustup_init" https://sh.rustup.rs; then
|
||||
rm -f "$rustup_init"
|
||||
die "Failed to download rustup installer."
|
||||
fi
|
||||
if ! sh "$rustup_init" -y --profile minimal >/dev/null 2>&1; then
|
||||
rm -f "$rustup_init"
|
||||
die "rustup installation failed."
|
||||
fi
|
||||
rm -f "$rustup_init"
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! rust_version_ok; then
|
||||
die "Rust toolchain installed but cargo still reports an insufficient version. FIPS requires 1.94.1+."
|
||||
fi
|
||||
print_success "Rust toolchain installed: $(cargo --version)."
|
||||
}
|
||||
|
||||
# Build FIPS from source via cargo. Outputs the binary path on stdout.
|
||||
build_fips_from_source() {
|
||||
print_info "Building FIPS from source ($FIPS_REPO)..."
|
||||
ensure_rust
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp -d)"
|
||||
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
|
||||
|
||||
if ! git clone --depth 1 "$FIPS_REPO" "$tmp/fips"; then
|
||||
die "Failed to clone FIPS repo: $FIPS_REPO"
|
||||
fi
|
||||
|
||||
local fips_dir="$tmp/fips"
|
||||
print_info "FIPS: running cargo build --release (this can take several minutes)..."
|
||||
if ! ( cd "$fips_dir" && cargo build --release ); then
|
||||
die "cargo build --release failed for FIPS. FIPS is required."
|
||||
fi
|
||||
|
||||
local fips_bin=""
|
||||
for cand in \
|
||||
"$fips_dir/target/release/fips" \
|
||||
"$fips_dir/target/x86_64-unknown-linux-gnu/release/fips"; do
|
||||
if [[ -x "$cand" ]] && file "$cand" 2>/dev/null | grep -qi ELF; then
|
||||
fips_bin="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$fips_bin" ]]; then
|
||||
fips_bin="$(find "$fips_dir/target" -type f -name fips -executable 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[[ -n "$fips_bin" ]] || die "FIPS build succeeded but no 'fips' binary was found."
|
||||
printf '%s' "$fips_bin"
|
||||
}
|
||||
|
||||
install_fips() {
|
||||
print_info "Installing FIPS (required, not optional)..."
|
||||
|
||||
# If source build was requested, install the extra build-time apt deps.
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
print_info "Installing FIPS source-build deps (libclang-dev for bindgen)..."
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
local fips_bin=""
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
else
|
||||
if ! fips_bin="$(download_fips_prebuilt)"; then
|
||||
print_warning "Prebuilt FIPS unavailable — falling back to source build."
|
||||
# Install source-build deps on the fallback path too.
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
fi
|
||||
fi
|
||||
[[ -n "$fips_bin" && -x "$fips_bin" ]] || die "FIPS binary not available."
|
||||
|
||||
local prefix_bin="$INSTALL_PREFIX/bin"
|
||||
local SUDO
|
||||
SUDO="$(sudo_for "$INSTALL_PREFIX")"
|
||||
$SUDO mkdir -p "$prefix_bin"
|
||||
$SUDO cp -f "$fips_bin" "$prefix_bin/fips"
|
||||
$SUDO chmod +x "$prefix_bin/fips"
|
||||
print_success "FIPS binary installed to $prefix_bin/fips"
|
||||
# Clean up the temp binary (download_fips_prebuilt copies to a stable
|
||||
# mktemp file; build_fips_from_source uses a RETURN-trapped dir so its
|
||||
# output is already gone, but rm -f is safe on either).
|
||||
rm -f "$fips_bin" 2>/dev/null || true
|
||||
|
||||
# setcap for CAP_NET_ADMIN (needed to create a TUN interface in managed mode).
|
||||
local setcap_cmd=""
|
||||
if command -v setcap >/dev/null 2>&1; then
|
||||
setcap_cmd="setcap"
|
||||
elif command -v sudo >/dev/null 2>&1 && $SUDO command -v setcap >/dev/null 2>&1; then
|
||||
setcap_cmd="$SUDO setcap"
|
||||
fi
|
||||
|
||||
if [[ -n "$setcap_cmd" ]]; then
|
||||
if $setcap_cmd cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
if $SUDO setcap cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
print_warning "setcap failed. FIPS managed mode (TUN) won't work without CAP_NET_ADMIN."
|
||||
print_warning "Install 'libcap2-bin' and re-run, or run a system FIPS instance and set fips_mode=attach."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_warning "'setcap' not found (libcap2-bin not installed)."
|
||||
print_warning "FIPS managed mode won't work without CAP_NET_ADMIN."
|
||||
print_warning "Install libcap2-bin (apt-get install -y libcap2-bin) and re-run, or run a system FIPS instance and set fips_mode=attach."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Prebuilt binary download -------------------------------------------
|
||||
|
||||
download_binary() {
|
||||
# Outputs the downloaded binary path on stdout. Returns non-zero on failure
|
||||
# so the caller can fall back to a source build.
|
||||
print_info "Querying Gitea for latest release..."
|
||||
local api_json tag url out
|
||||
if ! api_json="$(curl -fsSL "$GITEA_API/releases/latest" 2>/dev/null)"; then
|
||||
print_warning "Could not reach Gitea releases API (no release yet, or network issue)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
print_warning "Could not parse tag_name from Gitea release JSON."
|
||||
return 1
|
||||
fi
|
||||
|
||||
url="$GITEA_DOWNLOAD_BASE/$tag/$BIN_NAME"
|
||||
out="$(mktemp -t sovereign_browser_download.XXXXXX)"
|
||||
print_info "Downloading prebuilt binary: $url"
|
||||
if ! curl -fsSL -o "$out" "$url"; then
|
||||
rm -f "$out" 2>/dev/null || true
|
||||
print_warning "Download failed for $url"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! file "$out" 2>/dev/null | grep -qi ELF; then
|
||||
rm -f "$out" 2>/dev/null || true
|
||||
print_warning "Downloaded file is not an ELF binary."
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$out"
|
||||
print_success "Downloaded sovereign_browser $tag."
|
||||
}
|
||||
|
||||
# --- Source build fallback -----------------------------------------------
|
||||
|
||||
build_from_source() {
|
||||
print_info "Building sovereign_browser from source ($SB_REPO)..."
|
||||
local tmp
|
||||
tmp="$(mktemp -d)"
|
||||
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
|
||||
|
||||
if ! git clone --depth 1 "$SB_REPO" "$tmp/sovereign_browser"; then
|
||||
die "Failed to clone sovereign_browser repo: $SB_REPO"
|
||||
fi
|
||||
|
||||
if ! ( cd "$tmp/sovereign_browser" && make ); then
|
||||
die "Source build failed (make). Ensure build deps are installed."
|
||||
fi
|
||||
|
||||
[[ -x "$tmp/sovereign_browser/$BIN_NAME" ]] || die "Build finished but ./$BIN_NAME was not produced."
|
||||
|
||||
local out
|
||||
out="$(mktemp -t sovereign_browser_built.XXXXXX)"
|
||||
cp -f "$tmp/sovereign_browser/$BIN_NAME" "$out"
|
||||
chmod +x "$out"
|
||||
printf '%s\n' "$out"
|
||||
print_success "Built sovereign_browser from source."
|
||||
}
|
||||
|
||||
# --- Install binary + wrapper -------------------------------------------
|
||||
|
||||
install_binary() {
|
||||
local src="$1"
|
||||
local prefix_bin="$INSTALL_PREFIX/bin"
|
||||
local SUDO
|
||||
SUDO="$(sudo_for "$INSTALL_PREFIX")"
|
||||
|
||||
print_info "Installing binary to $prefix_bin/$BIN_NAME"
|
||||
$SUDO mkdir -p "$prefix_bin"
|
||||
$SUDO cp -f "$src" "$prefix_bin/$BIN_NAME"
|
||||
$SUDO chmod +x "$prefix_bin/$BIN_NAME"
|
||||
|
||||
# Write the kebab-case wrapper that detaches the GUI.
|
||||
local wrapper="$prefix_bin/sovereign-browser"
|
||||
local real_bin="$prefix_bin/$BIN_NAME"
|
||||
print_info "Installing wrapper to $wrapper"
|
||||
|
||||
# Write to a temp file then move with sudo if needed.
|
||||
local wtmp
|
||||
wtmp="$(mktemp -t sovereign-browser-wrapper.XXXXXX)"
|
||||
cat > "$wtmp" <<WRAPPER_EOF
|
||||
#!/bin/bash
|
||||
# sovereign-browser — user-facing wrapper that detaches the GUI.
|
||||
# Mirrors a subset of browser.sh: start | stop | restart | status | log
|
||||
set -euo pipefail
|
||||
|
||||
SB_BIN="$real_bin"
|
||||
SB_DATA_DIR="\${HOME}/.sovereign_browser"
|
||||
SB_PID_FILE="\${SB_DATA_DIR}/browser.pid"
|
||||
SB_LOG_FILE="\${SB_DATA_DIR}/browser.log"
|
||||
|
||||
sb_start() {
|
||||
mkdir -p "\$SB_DATA_DIR"
|
||||
# If already running, just print status.
|
||||
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "sovereign_browser already running (PID \$(cat "\$SB_PID_FILE"))" >&2
|
||||
return 0
|
||||
fi
|
||||
nohup "\$SB_BIN" "\$@" > "\$SB_LOG_FILE" 2>&1 &
|
||||
local pid=\$!
|
||||
echo "\$pid" > "\$SB_PID_FILE"
|
||||
disown "\$pid" 2>/dev/null || true
|
||||
echo "sovereign_browser started (PID \$pid)" >&2
|
||||
}
|
||||
|
||||
sb_stop() {
|
||||
if [[ ! -f "\$SB_PID_FILE" ]]; then
|
||||
echo "sovereign_browser is not running (no PID file)" >&2
|
||||
return 0
|
||||
fi
|
||||
local pid
|
||||
pid="\$(cat "\$SB_PID_FILE" 2>/dev/null || true)"
|
||||
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
|
||||
kill "\$pid" 2>/dev/null || true
|
||||
echo "sovereign_browser stopped (PID \$pid)" >&2
|
||||
else
|
||||
echo "sovereign_browser was not running (stale PID file)" >&2
|
||||
fi
|
||||
rm -f "\$SB_PID_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
sb_status() {
|
||||
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "sovereign_browser is running (PID \$(cat "\$SB_PID_FILE"))" >&2
|
||||
return 0
|
||||
else
|
||||
echo "sovereign_browser is not running" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
sb_log() {
|
||||
tail -n 50 "\$SB_LOG_FILE" 2>/dev/null || echo "(no log file yet)" >&2
|
||||
}
|
||||
|
||||
case "\${1:-start}" in
|
||||
start) shift 2>/dev/null || true; sb_start "\$@" ;;
|
||||
stop) sb_stop ;;
|
||||
restart) sb_stop; shift 2>/dev/null || true; sb_start "\$@" ;;
|
||||
status) sb_status ;;
|
||||
log) sb_log ;;
|
||||
*) sb_start "\$@" ;; # default: treat all args as browser args
|
||||
esac
|
||||
WRAPPER_EOF
|
||||
|
||||
$SUDO mv -f "$wtmp" "$wrapper"
|
||||
$SUDO chmod +x "$wrapper"
|
||||
|
||||
print_success "Wrapper installed: $wrapper"
|
||||
if [[ ":${PATH}:" != *":$prefix_bin:"* ]]; then
|
||||
print_warning "$prefix_bin is not in your PATH. Add it: export PATH=\"$prefix_bin:\$PATH\""
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ----------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
detect_platform
|
||||
|
||||
# Summary + confirmation
|
||||
local method="prebuilt binary from Gitea"
|
||||
$BUILD_FROM_SOURCE && method="source build from $SB_REPO"
|
||||
|
||||
local fips_method="prebuilt binary from GitHub releases"
|
||||
$BUILD_FIPS_FROM_SOURCE && fips_method="source build ($FIPS_REPO, cargo)"
|
||||
|
||||
# Use print_info so colors render via `echo -e` (a plain `cat` heredoc
|
||||
# would print the literal \033 escape sequences).
|
||||
print_info "sovereign_browser will be installed:"
|
||||
print_info " Install prefix : $INSTALL_PREFIX"
|
||||
print_info " Binary method : $method"
|
||||
print_info " FIPS : $fips_method, CAP_NET_ADMIN set"
|
||||
print_info " Tor : apt package 'tor'"
|
||||
print_info " apt deps : ${APT_PACKAGES[*]}"
|
||||
|
||||
if ! $ASSUME_YES; then
|
||||
print_warning "This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N]"
|
||||
# Read from /dev/tty, not stdin: in `curl | bash` mode stdin is the
|
||||
# curl pipe (the script text), so a plain `read` would hit EOF and
|
||||
# abort immediately. /dev/tty is the user's terminal.
|
||||
if [[ -r /dev/tty ]]; then
|
||||
read -r reply < /dev/tty || reply=""
|
||||
else
|
||||
# No tty available (e.g. non-interactive CI) — require --yes.
|
||||
die "No tty available for confirmation. Re-run with --yes to skip prompts."
|
||||
fi
|
||||
reply="${reply:-n}"
|
||||
if [[ ! "${reply,,}" =~ ^y(es)?$ ]]; then
|
||||
die "Aborted by user."
|
||||
fi
|
||||
fi
|
||||
|
||||
install_deps
|
||||
install_fips
|
||||
|
||||
local bin_path=""
|
||||
if $BUILD_FROM_SOURCE; then
|
||||
bin_path="$(build_from_source)"
|
||||
else
|
||||
if ! bin_path="$(download_binary)"; then
|
||||
print_warning "Prebuilt binary unavailable — falling back to source build."
|
||||
bin_path="$(build_from_source)"
|
||||
fi
|
||||
fi
|
||||
|
||||
install_binary "$bin_path"
|
||||
rm -f "$bin_path" 2>/dev/null || true
|
||||
|
||||
print_success "sovereign_browser installed."
|
||||
cat >&2 <<EOF
|
||||
${GREEN}[SUCCESS]${NC} Next steps:
|
||||
Run: sovereign-browser
|
||||
Or: sovereign-browser --login-method generate
|
||||
Or: sovereign-browser --login-method generate --url https://example.com
|
||||
Data dir: ~/.sovereign_browser/
|
||||
Logs: sovereign-browser log
|
||||
Stop: sovereign-browser stop
|
||||
Status: sovereign-browser status
|
||||
EOF
|
||||
}
|
||||
|
||||
main "$@"
|
||||
261
plans/bookmarks-tree-view.md
Normal file
261
plans/bookmarks-tree-view.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
|
||||
|
||||
## Goal
|
||||
|
||||
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
|
||||
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
|
||||
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
|
||||
|
||||
## NIP-51 Compliance + Privacy — How Nesting Works
|
||||
|
||||
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
|
||||
|
||||
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
|
||||
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
|
||||
|
||||
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
|
||||
|
||||
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
|
||||
|
||||
```
|
||||
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
|
||||
```
|
||||
|
||||
### Event layout (one per folder)
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30003,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The NIP-44 encrypted `content` plaintext is:
|
||||
|
||||
```json
|
||||
{"path": "Work/Projects/Secret", "bookmarks": [
|
||||
["bookmark", "https://example.com", "Example", 1690000000]
|
||||
]}
|
||||
```
|
||||
|
||||
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
|
||||
|
||||
### Tree reconstruction (client-side)
|
||||
|
||||
1. Fetch all `kind=30003` events for the user's pubkey.
|
||||
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
|
||||
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
|
||||
4. Attach the decrypted `bookmarks` array to that leaf node.
|
||||
|
||||
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
|
||||
|
||||
### Why this is safe and nostr-ish
|
||||
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
|
||||
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
|
||||
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
|
||||
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
|
||||
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
|
||||
B --> C["Read path from plaintext JSON"]
|
||||
C --> D["Split path on / to get segments"]
|
||||
D --> E["Build trie of folders via node_ensure_path"]
|
||||
E --> F["Attach decrypted bookmarks to leaf node"]
|
||||
F --> G["Render tree on sovereign://bookmarks page"]
|
||||
G --> H["User adds/moves/renames/deletes"]
|
||||
H --> I["Compute HMAC d tag for affected path(s)"]
|
||||
I --> J["NIP-44 encrypt new content and publish kind 30003"]
|
||||
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
|
||||
|
||||
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
|
||||
|
||||
#### New struct
|
||||
|
||||
```c
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last segment of path, e.g. "Secret" */
|
||||
char *path; /* full path, e.g. "Work/Projects/Secret" */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_t;
|
||||
```
|
||||
|
||||
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
|
||||
|
||||
#### New / changed API
|
||||
|
||||
Keep the existing public functions working (they now operate on paths, not flat names):
|
||||
|
||||
- `bookmarks_add(const char *path, const char *url, const char *title)` — `path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
|
||||
- `bookmarks_remove(const char *path, const char *url)`
|
||||
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
|
||||
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
|
||||
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
|
||||
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
|
||||
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
|
||||
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
|
||||
|
||||
#### Internal helpers
|
||||
|
||||
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
|
||||
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
|
||||
- `static void node_free(bookmark_node_t *node)` — recursive free.
|
||||
- `static void compute_hmac_key(unsigned char out[32])` — `HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
|
||||
- `static char *path_to_d_tag(const char *path)` — `HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
|
||||
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
|
||||
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
|
||||
|
||||
#### Loading
|
||||
|
||||
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
|
||||
1. Read the `d` tag.
|
||||
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
|
||||
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
|
||||
4. `node_ensure_path(root, path)`.
|
||||
5. Load bookmarks into that leaf.
|
||||
|
||||
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
|
||||
|
||||
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"have_signer": true,
|
||||
"tree": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"bookmarks": [],
|
||||
"children": [
|
||||
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
|
||||
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
|
||||
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
|
||||
|
||||
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
|
||||
|
||||
#### HTML
|
||||
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
|
||||
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
|
||||
- Add a path breadcrumb / parent selector in the add form.
|
||||
|
||||
#### JS
|
||||
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
|
||||
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
|
||||
- A bookmark list under the folder (collapsible).
|
||||
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
|
||||
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
|
||||
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
|
||||
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
|
||||
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
|
||||
|
||||
#### CSS
|
||||
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
|
||||
- Caret uses a CSS triangle that rotates 90° when expanded.
|
||||
- Indent `.tree-children` by `20px` per level.
|
||||
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
|
||||
|
||||
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
|
||||
|
||||
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
|
||||
|
||||
#### Layout
|
||||
|
||||
```
|
||||
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
|
||||
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
|
||||
[ ============ WebKitWebView ============ ]
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
|
||||
```c
|
||||
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
|
||||
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
```
|
||||
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
|
||||
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
|
||||
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
|
||||
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
|
||||
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
|
||||
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
|
||||
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
|
||||
|
||||
#### Cross-tab refresh
|
||||
|
||||
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
|
||||
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
|
||||
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
|
||||
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
|
||||
|
||||
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
|
||||
|
||||
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
|
||||
|
||||
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
|
||||
|
||||
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
|
||||
|
||||
### 6. History filter — [`src/history.c`](src/history.c:27)
|
||||
|
||||
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
|
||||
|
||||
### 7. Tests
|
||||
|
||||
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
|
||||
- `node_ensure_path` creates intermediate nodes.
|
||||
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
|
||||
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
|
||||
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
|
||||
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
|
||||
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
|
||||
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
|
||||
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
|
||||
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
|
||||
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
|
||||
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
|
||||
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
|
||||
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
|
||||
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
|
||||
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
|
||||
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
|
||||
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |
|
||||
265
plans/bookmarks.md
Normal file
265
plans/bookmarks.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Plan: Nostr-Encrypted Bookmarks with Directories
|
||||
|
||||
## Overview
|
||||
|
||||
Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into **directories** (folders). A **bookmark button** in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a `sovereign://bookmarks` page.
|
||||
|
||||
## Event Design
|
||||
|
||||
### Kind 30003 (NIP-51 Bookmark Sets) — one event per directory
|
||||
|
||||
Per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md), **kind 30003** is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a `d` tag = directory name.
|
||||
|
||||
NIP-51 encryption pattern (line 11):
|
||||
> Private items are specified in a JSON array that mimics the structure of the event `tags` array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the `.content`.
|
||||
|
||||
### Event structure (one per directory)
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30003,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [["d", "<directory name>"]],
|
||||
"content": "<NIP-44 encrypted JSON array of bookmarks>",
|
||||
"created_at": <unix timestamp>,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Plaintext format (encrypted in `content`)
|
||||
|
||||
```json
|
||||
[
|
||||
["bookmark", "https://example.com", "Example", 1690000000],
|
||||
["bookmark", "https://nostr.com", "Nostr", 1690000001]
|
||||
]
|
||||
```
|
||||
|
||||
### Directory model
|
||||
|
||||
- Each directory = one kind 30003 event with `d` = directory name.
|
||||
- A default directory `"General"` holds bookmarks that aren't assigned to a specific folder.
|
||||
- The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation).
|
||||
- The list of directory names is derived from the `d` tags of all the user's kind 30003 events — no separate "directory list" event needed.
|
||||
|
||||
### Why kind 30003 (sets) instead of kind 10003 (flat list)?
|
||||
|
||||
| Feature | Kind 10003 | Kind 30003 |
|
||||
|---------|-----------|-----------|
|
||||
| Directories/folders | Not supported (flat list) | **Supported** — each `d` tag is a directory |
|
||||
| Replaceable | Normal replaceable (one event) | Parameterized replaceable (one event per `d` tag) |
|
||||
| NIP-51 standard | Yes — "Bookmarks" | Yes — "Bookmark sets" |
|
||||
| NIP-44 encryption | Documented | Documented (same pattern) |
|
||||
|
||||
Since you want directories, **kind 30003** is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups."
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["User clicks bookmark button"] --> B["Show directory picker dialog"]
|
||||
B --> C{"Select existing or create new?"}
|
||||
C -->|Existing| D["Add bookmark to that directory"]
|
||||
C -->|Create new| E["Enter new directory name"]
|
||||
E --> D
|
||||
D --> F["Update in-memory list for that directory"]
|
||||
F --> G["Serialize directory bookmarks to JSON"]
|
||||
G --> H["NIP-44 encrypt to self"]
|
||||
H --> I["Build kind 30003 event with d=directory"]
|
||||
I --> J["Sign with signer"]
|
||||
J --> K["Publish to bootstrap relays"]
|
||||
J --> L["Store in SQLite db_store_event"]
|
||||
|
||||
M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"]
|
||||
N --> O["Store in SQLite"]
|
||||
O --> P["For each event: decrypt content with NIP-44"]
|
||||
P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"]
|
||||
Q --> R["Populate dropdown menu + bookmarks page"]
|
||||
|
||||
S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"]
|
||||
T --> U["Add/edit/delete/move bookmarks between directories"]
|
||||
U --> V["Save re-encrypt publish affected directory event"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### New files: `src/bookmarks.h` / `src/bookmarks.c`
|
||||
|
||||
```c
|
||||
/* bookmarks.h */
|
||||
#pragma once
|
||||
#include <glib.h>
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#define BOOKMARKS_MAX_PER_DIR 500
|
||||
#define BOOKMARKS_DIR_NAME_MAX 128
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
char *title;
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_t;
|
||||
|
||||
/* Initialize the bookmarks module. Loads cached bookmarks from SQLite
|
||||
* and decrypts them if a signer is available. Call after login. */
|
||||
int bookmarks_init(void);
|
||||
|
||||
/* Free the in-memory bookmark list. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* Get the list of directories (read-only). */
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
|
||||
|
||||
/* Get a specific directory by name (read-only). Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
|
||||
/* Add a bookmark to a directory. If the directory doesn't exist, it's
|
||||
* created. Updates the in-memory list, re-encrypts, publishes a new
|
||||
* kind 30003 event for that directory, and stores in SQLite.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_dir, const char *url,
|
||||
const char *to_dir);
|
||||
|
||||
/* Create a new empty directory. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if directory already exists / error. */
|
||||
int bookmarks_create_dir(const char *name);
|
||||
|
||||
/* Rename a directory. Publishes a new event with the new d-tag and
|
||||
* deletes the old one (kind 5 deletion event).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_rename_dir(const char *old_name, const char *new_name);
|
||||
|
||||
/* Delete a directory. Optionally move bookmarks to "General" first.
|
||||
* Publishes a kind 5 deletion event for the old directory event.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general);
|
||||
|
||||
/* Load bookmarks for a directory from a decrypted kind 30003 event's
|
||||
* content JSON. Called internally by bookmarks_init. */
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
|
||||
|
||||
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */
|
||||
char *bookmarks_dir_to_json(const char *dir_name);
|
||||
|
||||
/* Build and publish the kind 30003 event for a directory.
|
||||
* Encrypts the directory's bookmarks with NIP-44 (self-to-self),
|
||||
* signs, publishes to bootstrap relays, and stores in SQLite.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_publish_dir(const char *dir_name);
|
||||
```
|
||||
|
||||
### Bookmark button in the toolbar
|
||||
|
||||
In `tab_manager.c`'s `tab_create()`, add a bookmark button to the toolbar (right of the URL entry):
|
||||
|
||||
```c
|
||||
/* Bookmark button — right of the URL entry. */
|
||||
GtkWidget *bookmark_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
|
||||
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
|
||||
GTK_ICON_SIZE_MENU));
|
||||
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
|
||||
g_signal_connect(bookmark_btn, "clicked",
|
||||
G_CALLBACK(on_bookmark_clicked), tab);
|
||||
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
|
||||
```
|
||||
|
||||
The `on_bookmark_clicked` handler:
|
||||
1. Gets the active tab's current URL and title.
|
||||
2. Shows a **directory picker dialog** — a small GTK dialog with:
|
||||
- A combo box (dropdown) of existing directories + "New Directory…" option
|
||||
- If "New Directory…" is selected, show a text entry for the new name
|
||||
- An "Add" button and a "Cancel" button
|
||||
3. On "Add", calls `bookmarks_add(dir, url, title)`.
|
||||
4. Shows a brief confirmation (e.g. "Bookmarked to <dir>").
|
||||
|
||||
### sovereign://bookmarks page
|
||||
|
||||
Add a `handle_bookmarks_page` function in `nostr_bridge.c`:
|
||||
|
||||
- Shows directories as a folder list on the left (or as section headings)
|
||||
- Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons
|
||||
- "Add Bookmark" form (URL + title + directory dropdown)
|
||||
- "Create Directory" button
|
||||
- "Rename Directory" / "Delete Directory" buttons per directory
|
||||
- Uses `sovereign://bookmarks/add?dir=...&url=...&title=...`, `sovereign://bookmarks/delete?dir=...&url=...`, `sovereign://bookmarks/move?from=...&to=...&url=...`, `sovereign://bookmarks/createdir?name=...`, `sovereign://bookmarks/renamedir?old=...&new=...`, `sovereign://bookmarks/deletedir?name=...` routes
|
||||
- Styled consistently with settings/profile pages (dark theme, monospace)
|
||||
- In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks"
|
||||
|
||||
### Dropdown menu integration
|
||||
|
||||
In `tab_manager.c`'s `build_hamburger_menu()`, add a "Bookmarks" submenu (like "Recents") that:
|
||||
- Shows directories as sub-submenus (nested GTK menus)
|
||||
- Each directory submenu lists its bookmarks as clickable items
|
||||
- Clicking a bookmark navigates the active tab to that URL
|
||||
- "Manage Bookmarks…" item at the bottom opens `sovereign://bookmarks`
|
||||
- Rebuilds on each show (like `on_history_menu_show`)
|
||||
|
||||
### Relay fetch integration
|
||||
|
||||
Add kind 30003 to the relay fetch filter in `relay_fetch.c`:
|
||||
```c
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* NEW: bookmark sets */
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
The existing `events` + `event_tags` tables store kind 30003 events. The `event_tags` table allows querying by `d` tag to find all directory events for a pubkey. No schema changes needed.
|
||||
|
||||
### Makefile
|
||||
|
||||
Add `src/bookmarks.c` to `SRC`.
|
||||
|
||||
---
|
||||
|
||||
## File change summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/bookmarks.h` | NEW — bookmark/directory data structures + API |
|
||||
| `src/bookmarks.c` | NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management |
|
||||
| `src/nostr_bridge.c` | Add `sovereign://bookmarks` page + action routes (add/delete/move/createdir/renamedir/deletedir) |
|
||||
| `src/tab_manager.c` | Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu |
|
||||
| `src/main.c` | Call `bookmarks_init()` after login, `bookmarks_cleanup()` on shutdown |
|
||||
| `src/relay_fetch.c` | Add kind 30003 to the bootstrap fetch filter |
|
||||
| `Makefile` | Add `src/bookmarks.c` to SRC |
|
||||
|
||||
---
|
||||
|
||||
## Threading and read-only mode
|
||||
|
||||
- **Signing required**: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be *read* from SQLite but not *modified*. The bookmark button and add/delete/move UI are disabled.
|
||||
- **Background publish**: `bookmarks_publish_dir()` calls `synchronous_publish_event_with_progress()` which blocks. Run in a `g_thread_new()` background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async.
|
||||
- **SQLite thread safety**: `SQLITE_OPEN_FULLMUTEX` already set.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
1. **No signer (read-only / no-login)**: bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks."
|
||||
2. **No cached bookmarks**: empty state — "No bookmarks yet. Click the bookmark button to save a page."
|
||||
3. **Decryption failure**: show error, treat that directory as empty.
|
||||
4. **Directory deletion**: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event.
|
||||
5. **Directory rename**: publish new kind 30003 with new `d` tag + kind 5 deletion for old `d` tag.
|
||||
6. **Large directories**: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
|
||||
7. **Conflict resolution**: kind 30003 is parameterized replaceable — latest `created_at` per `d` tag wins. Next relay fetch reconciles.
|
||||
8. **Default directory**: "General" is always present (created on first bookmark if no directories exist).
|
||||
176
plans/chat-page-discrete-events.md
Normal file
176
plans/chat-page-discrete-events.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Chat Page — Discrete Event Model
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign the `sovereign://agents/chat` page to work with sovereign_browser's
|
||||
**discrete event** Nostr interaction model (no live subscriptions). The page
|
||||
needs explicit Save/Refresh buttons for conversations and skills, matching
|
||||
how the rest of the browser works.
|
||||
|
||||
## What's wrong now
|
||||
|
||||
The current chat page tries to mimic ai.html's live-subscription model:
|
||||
- It auto-saves conversations after each agent response (via a debounced
|
||||
`scheduleSave()`)
|
||||
- It fetches the conversation list on page load but has no way to refresh
|
||||
- It fetches skills on page load but has no way to refresh
|
||||
- New Chat saves an empty conversation immediately (good), but there's no
|
||||
explicit save button for renaming or manual saves
|
||||
|
||||
The user wants explicit control: **Save** to publish, **Refresh** to fetch.
|
||||
|
||||
## Design
|
||||
|
||||
### Conversation list (left pane, top section)
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [+ New Chat] [↻ Refresh] │
|
||||
├─────────────────────────────┤
|
||||
│ > Capital of France [×] │ ← selected, click to load
|
||||
│ EU population [×] │
|
||||
│ Hello world [×] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- **+ New Chat** — Creates a new local session (does NOT save to Nostr yet).
|
||||
The conversation is "unsaved" until the user sends a message or clicks Save.
|
||||
- **↻ Refresh** — Fetches the conversation list from Nostr (one-shot relay
|
||||
query via `relay_fetch.c`) and updates the list. Shows a "Refreshing..."
|
||||
state while fetching.
|
||||
- **Click a conversation** — Loads it from the local SQLite cache (fast, no
|
||||
relay fetch). If the conversation isn't in the cache, fetch it from Nostr.
|
||||
- **[×] delete button** — Deletes the conversation (publishes kind 5
|
||||
tombstone, removes from local cache).
|
||||
|
||||
### Chat thread (right pane)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Capital of France [✏ Rename] [💾] │ ← conversation header
|
||||
├─────────────────────────────────────────────┤
|
||||
│ You: What is the capital of France? │
|
||||
│ Assistant: The capital of France is Paris. │
|
||||
│ │
|
||||
│ [⋯] │ ← dot-menu on each bubble
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [Type a message... ] [Send] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **✏ Rename** — Inline edit the conversation title. Saves to Nostr on Enter
|
||||
or blur.
|
||||
- **💾 Save** — Explicitly saves the conversation to Nostr (publishes kind 30078
|
||||
with the current messages + title). Shows "Saved!" feedback.
|
||||
- **Auto-save is removed** — The user must click Save to persist. The only
|
||||
exception: the first message exchange auto-saves once (to create the Nostr
|
||||
event), so the conversation appears in the list on other devices. After
|
||||
that, explicit Save only.
|
||||
|
||||
### Skills list (left pane, bottom section)
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Skills [↻ Refresh] │
|
||||
├─────────────────────────────┤
|
||||
│ [✓] Web scraper [requires: │
|
||||
│ browser,fs] [×] │
|
||||
│ [ ] Code reviewer [requires: │
|
||||
│ shell] [×] │
|
||||
│ [+ Create Skill] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- **↻ Refresh** — Fetches skills from Nostr (one-shot relay query) and
|
||||
updates the list.
|
||||
- **Checkbox** — Toggle skill selection (saved locally, not to Nostr).
|
||||
- **[×] delete** — Deletes the skill (kind 5 tombstone) — only for
|
||||
user-authored skills.
|
||||
- **+ Create Skill** — Opens the create form. "Publish" button saves to
|
||||
Nostr.
|
||||
|
||||
### New endpoints needed
|
||||
|
||||
- **`sovereign://agents/conversations/refresh`** (GET) — Triggers a one-shot
|
||||
relay fetch for kind 30078 events with `t:client-ai-chat-v1` by the user's
|
||||
pubkey. Stores results in SQLite. Returns `{"status":"refreshed","count":N}`
|
||||
when done. This is a blocking call (waits for relay response or timeout).
|
||||
|
||||
- **`sovereign://agents/skills/refresh`** (GET) — Triggers a one-shot relay
|
||||
fetch for kind 31123 events. Stores in SQLite. Returns
|
||||
`{"status":"refreshed","count":N}`.
|
||||
|
||||
- **`sovereign://agents/conversations/rename`** (GET with `?id=...&title=...`)
|
||||
— Renames a conversation. Updates the local SQLite cache and re-publishes
|
||||
the kind 30078 event with the new title. Returns `{"status":"renamed"}`.
|
||||
|
||||
### Changes to existing endpoints
|
||||
|
||||
- **`sovereign://agents/conversations/new`** — Should NOT save to Nostr
|
||||
immediately. Just create a local session. The conversation is saved to
|
||||
Nostr on the first explicit Save (or after the first message exchange).
|
||||
|
||||
- **`sovereign://agents/conversations/save`** — Keep as-is (explicit save).
|
||||
Remove the auto-save call from `applyStatus()` in chat.js.
|
||||
|
||||
### Changes to `www/agents/chat.js`
|
||||
|
||||
1. **Remove `scheduleSave()` / `doSave()` auto-save** — Delete the debounced
|
||||
auto-save logic. The user clicks Save explicitly.
|
||||
|
||||
2. **Add Refresh button handler** — `refreshConvList()` calls
|
||||
`sovereign://agents/conversations/refresh`, then re-fetches the list.
|
||||
|
||||
3. **Add Rename button handler** — `renameConv(id)` prompts for a new title,
|
||||
calls `sovereign://agents/conversations/rename?id=...&title=...`.
|
||||
|
||||
4. **Add Save button handler** — `saveConv()` calls
|
||||
`sovereign://agents/conversations/save?title=...` explicitly.
|
||||
|
||||
5. **Add Skills Refresh button handler** — `refreshSkills()` calls
|
||||
`sovereign://agents/skills/refresh`, then re-fetches the skills list.
|
||||
|
||||
6. **First-message auto-save** — After the first agent response in a new
|
||||
conversation, auto-save once (so the conversation exists on Nostr). After
|
||||
that, no auto-save.
|
||||
|
||||
### Changes to `src/nostr_bridge.c`
|
||||
|
||||
1. **Add `handle_agents_conversations_refresh()`** — Calls a new
|
||||
`agent_conversations_refresh()` function that does a one-shot relay fetch.
|
||||
|
||||
2. **Add `handle_agents_skills_refresh()`** — Calls a new
|
||||
`agent_skills_refresh()` function that does a one-shot relay fetch.
|
||||
|
||||
3. **Add `handle_agents_conversations_rename()`** — Calls
|
||||
`agent_conversations_rename(id, title)`.
|
||||
|
||||
4. **Update `handle_agents_conversations_new()`** — Remove the immediate
|
||||
`agent_conversations_save()` call. Just create the local session.
|
||||
|
||||
### Changes to `src/agent_conversations.c` / `src/agent_skills.c`
|
||||
|
||||
1. **Add `agent_conversations_refresh()`** — One-shot relay fetch for kind
|
||||
30078 `t:client-ai-chat-v1` events by the user's pubkey. Store in SQLite.
|
||||
This reuses the relay fetch logic from `relay_fetch.c` but with a specific
|
||||
filter.
|
||||
|
||||
2. **Add `agent_conversations_rename(id, title)`** — Fetch the existing event,
|
||||
decrypt, update the title, re-encrypt, re-publish.
|
||||
|
||||
3. **Add `agent_skills_refresh()`** — One-shot relay fetch for kind 31123
|
||||
events. Store in SQLite.
|
||||
|
||||
### Changes to `www/agents/chat.html`
|
||||
|
||||
Add the Refresh, Rename, and Save buttons to the HTML structure.
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1:** Add Refresh buttons (conversations + skills) and the refresh
|
||||
endpoints. Remove auto-save. Add explicit Save button.
|
||||
|
||||
**Phase 2:** Add Rename button and endpoint.
|
||||
|
||||
**Phase 3:** First-message auto-save (so new conversations appear on Nostr
|
||||
after the first exchange, but subsequent saves are explicit).
|
||||
174
plans/chat-sidebar-tabbed.md
Normal file
174
plans/chat-sidebar-tabbed.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Chat Sidebar + Tabbed Layout
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign the agent chat UI for two use cases:
|
||||
1. **Tabbed chat page** — Split the current cluttered `sovereign://agents/chat`
|
||||
page into 3 tabs: Chat, Conversations, Skills.
|
||||
2. **Sidebar mode** — Show the chat in a narrow left-hand sidebar alongside
|
||||
a web page on the right, so the user can chat about the page they're
|
||||
viewing.
|
||||
|
||||
## Current state
|
||||
|
||||
The `sovereign://agents/chat` page has a two-pane layout:
|
||||
- Left pane: conversation list + skills list (cluttered)
|
||||
- Right pane: chat messages + input
|
||||
|
||||
This is too much for a sidebar. The user wants tabs to organize it.
|
||||
|
||||
## Design
|
||||
|
||||
### Tabbed chat page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [Chat] [Conversations] [Skills] │ ← tab bar
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ (active tab content) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Tab 1: Chat** (default)
|
||||
- Chat messages (scrollable)
|
||||
- Status messages (system bubbles)
|
||||
- Input area (composer + send + stop)
|
||||
|
||||
**Tab 2: Conversations**
|
||||
- "+ New Chat" button
|
||||
- "↻ Refresh" button
|
||||
- Conversation list (click to load, rename, delete)
|
||||
|
||||
**Tab 3: Skills**
|
||||
- "↻ Refresh" button
|
||||
- "+ Create Skill" button
|
||||
- Skills list (checkboxes, edit, delete)
|
||||
- Skill editor (inline, expandable)
|
||||
|
||||
When a conversation is selected in Tab 2, switch to Tab 1 automatically.
|
||||
When a skill is toggled in Tab 3, stay on Tab 3.
|
||||
|
||||
### Sidebar mode
|
||||
|
||||
The sidebar is a **narrow version** of the chat page, shown alongside a
|
||||
web page. There are two approaches:
|
||||
|
||||
**Option A — Split view in the same tab:** The browser window splits into
|
||||
two webviews: a narrow left webview showing `sovereign://agents/chat` (in
|
||||
sidebar mode), and a right webview showing the web page. This requires
|
||||
GTK-level changes to `tab_manager.c` to support split views.
|
||||
|
||||
**Option B — Separate sidebar panel:** A GTK panel (like a GtkPaned or
|
||||
GtkBox) on the left side of the browser window, showing the chat UI
|
||||
natively (not as a webview). This is more work but gives a native feel.
|
||||
|
||||
**Option C — Pop-out sidebar tab:** The chat page detects when it's loaded
|
||||
in a narrow viewport and switches to a compact sidebar layout (tabs become
|
||||
icons, messages take full width). This is the simplest — no GTK changes,
|
||||
just CSS responsive design.
|
||||
|
||||
I recommend **Option A** (split view) for the first implementation — it
|
||||
reuses the existing chat page in a narrow webview, and the tabbed layout
|
||||
makes it work well in a narrow space.
|
||||
|
||||
### Split view implementation (Option A)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ [tab strip] │
|
||||
├─────────────────┬────────────────────────────────────────┤
|
||||
│ [Chat][Conv] │ │
|
||||
│ [Skills] │ │
|
||||
│ │ Web page (right webview) │
|
||||
│ Chat messages │ │
|
||||
│ ... │ │
|
||||
│ │ │
|
||||
│ [input area] │ │
|
||||
└─────────────────┴────────────────────────────────────────┘
|
||||
← sidebar (300px) ← web page (flex: 1)
|
||||
```
|
||||
|
||||
The sidebar is a second webview in the same tab, showing
|
||||
`sovereign://agents/chat` in a compact layout. The user can toggle the
|
||||
sidebar on/off via a menu item or keyboard shortcut.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — Tabbed chat page
|
||||
|
||||
1. **`www/agents/chat.html`** — Restructure into 3 tabs:
|
||||
```html
|
||||
<div id="tab-bar">
|
||||
<button class="tab active" data-tab="chat">Chat</button>
|
||||
<button class="tab" data-tab="conversations">Conversations</button>
|
||||
<button class="tab" data-tab="skills">Skills</button>
|
||||
</div>
|
||||
<div id="tab-chat" class="tab-content active">
|
||||
<!-- messages + status + input -->
|
||||
</div>
|
||||
<div id="tab-conversations" class="tab-content">
|
||||
<!-- new chat, refresh, conversation list -->
|
||||
</div>
|
||||
<div id="tab-skills" class="tab-content">
|
||||
<!-- refresh, create skill, skills list -->
|
||||
</div>
|
||||
```
|
||||
|
||||
2. **`www/agents/chat.css`** — Tab bar styling (horizontal buttons, active
|
||||
state), tab content (only active tab visible).
|
||||
|
||||
3. **`www/agents/chat.js`** — Tab switching logic:
|
||||
- `switchTab(name)` — hides all tab contents, shows the selected one,
|
||||
updates the active class on the tab buttons.
|
||||
- `loadConv()` — after loading a conversation, switch to the "chat" tab.
|
||||
- `newChat()` — after creating a new chat, switch to the "chat" tab.
|
||||
|
||||
### Phase 2 — Sidebar mode (split view)
|
||||
|
||||
1. **`src/tab_manager.c`** — Add a sidebar webview to each tab:
|
||||
- Each `tab_info_t` gets a `sidebar_webview` field.
|
||||
- The tab's container becomes a `GtkPaned` (horizontal) with the sidebar
|
||||
on the left and the main webview on the right.
|
||||
- The sidebar webview loads `sovereign://agents/chat` when shown.
|
||||
- A menu item "Toggle Agent Sidebar" (or keyboard shortcut) shows/hides
|
||||
the sidebar.
|
||||
|
||||
2. **`src/tab_manager.h`** — Add `sidebar_webview` to `tab_info_t`.
|
||||
|
||||
3. **`www/agents/chat.css`** — Responsive layout: when the viewport is
|
||||
narrow (< 400px), switch to a compact layout (tab bar becomes icons,
|
||||
messages take full width, smaller fonts).
|
||||
|
||||
4. **`src/main.c`** — Add a menu item "Toggle Agent Sidebar" and keyboard
|
||||
shortcut (e.g., Ctrl+Shift+A).
|
||||
|
||||
### Phase 3 — Sidebar-aware behavior
|
||||
|
||||
1. **Agent tools target the main webview** — When the agent calls browser
|
||||
tools (snapshot, click, eval), they should operate on the **main
|
||||
webview** (the web page), not the sidebar webview (the chat page).
|
||||
Update `agent_tools.c` to use the main webview, not the active webview.
|
||||
|
||||
2. **URL bar `;` shortcut** — When the user types `; <message>` in the URL
|
||||
bar, if the sidebar is open, send the message to the sidebar's chat. If
|
||||
not, open the sidebar and send the message.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `www/agents/chat.html` | Restructure into 3 tabs |
|
||||
| `www/agents/chat.css` | Tab bar styling, responsive sidebar layout |
|
||||
| `www/agents/chat.js` | Tab switching logic, sidebar-aware behavior |
|
||||
| `src/tab_manager.c` | Add sidebar webview, GtkPaned split, toggle |
|
||||
| `src/tab_manager.h` | Add sidebar_webview to tab_info_t |
|
||||
| `src/main.c` | Add "Toggle Agent Sidebar" menu item + shortcut |
|
||||
| `src/agent_tools.c` | Browser tools target main webview, not sidebar |
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1:** Tabbed chat page (HTML/CSS/JS only, no C changes).
|
||||
**Phase 2:** Sidebar mode (split view, GTK changes).
|
||||
**Phase 3:** Sidebar-aware behavior (agent tools target main webview).
|
||||
353
plans/cross-project-agent-sync.md
Normal file
353
plans/cross-project-agent-sync.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Cross-Project Agent Sync — sovereign_browser ↔ client ↔ didactyl
|
||||
|
||||
## Goal
|
||||
|
||||
Align sovereign_browser's embedded agent with the patterns used in `~/lt/client`
|
||||
(the web app) and `~/lt/didactyl` (the C agent daemon), so all three projects
|
||||
share LLM provider settings, skills, and conversations via Nostr kind 30078
|
||||
events. Also fix the immediate bug: messages don't appear in the chat UI.
|
||||
|
||||
## Three workstreams
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ BugFix │ │ SettingsSync │ │ ChatUI │
|
||||
│ Fix chat UI │────▶│ kind 30078 │────▶│ Port ai.html │
|
||||
│ message render │ │ d:user-settings │ │ chat layout │
|
||||
│ │ │ global_llm sync │ │ + Nostr persist │
|
||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workstream 1 — Fix chat UI message rendering (immediate bug)
|
||||
|
||||
**Problem:** The user sends "hello", sees the response in the console log, but
|
||||
nothing appears in the chat box — neither the user message nor the response.
|
||||
|
||||
**Root cause:** The chat UI's `fetchMessages()` calls
|
||||
`sovereign://agents/messages` which returns the message history as JSON. The
|
||||
`renderMessages()` function renders them. But the polling loop calls
|
||||
`updateStatus()` and `fetchMessages()` every 500ms. The status polling works
|
||||
(state transitions to complete), but `fetchMessages()` either:
|
||||
1. Fails silently (XHR error on `sovereign://agents/messages`), or
|
||||
2. The message count check `msgs.length !== lastCount` never triggers because
|
||||
the messages endpoint returns an empty array or the wrong format.
|
||||
|
||||
**Fix approach:**
|
||||
- Add console.error logging to `fetchMessages()` to see the actual XHR response.
|
||||
- Verify `sovereign://agents/messages` returns the correct JSON array format.
|
||||
- Check that `agent_chat_store_get_messages()` returns data in the format the
|
||||
UI expects (`[{role, content, tool_calls, tool_call_id}, ...]`).
|
||||
- The `renderMessage()` function expects `m.role`, `m.content`, `m.tool_calls`
|
||||
as fields — verify the store returns these.
|
||||
|
||||
**Files to modify:**
|
||||
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_chat_page()` JS,
|
||||
`handle_agents_messages()` endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Workstream 2 — Kind 30078 settings sync (shared LLM config)
|
||||
|
||||
### What the client does
|
||||
|
||||
The client stores all user settings in a single kind 30078 event with
|
||||
`d:user-settings`, NIP-44 self-encrypted. The schema (from
|
||||
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:140)):
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 2,
|
||||
"updatedAt": 1708646400,
|
||||
"global_llm": {
|
||||
"provider": "ppq",
|
||||
"api_key": "sk-...",
|
||||
"model": "claude-opus-4.6",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"max_tokens": 200000,
|
||||
"temperature": 0.7,
|
||||
"providers": [
|
||||
{
|
||||
"name": "ppq",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"api_key": "sk-...",
|
||||
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
|
||||
}
|
||||
],
|
||||
"favorites": ["claude-opus-4.6"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What didactyl does
|
||||
|
||||
Didactyl (C agent daemon) stores the same `global_llm` shape in its own
|
||||
pubkey's `d:user-settings` event. See
|
||||
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:357).
|
||||
|
||||
### What sovereign_browser should do — Option A: Merge into d:user-settings
|
||||
|
||||
sovereign_browser already has kind 30078 settings sync via
|
||||
[`settings_sync.c`](src/settings_sync.c), but it currently uses d-tag
|
||||
`"sovereign_browser"`. We will **migrate to the shared `d:user-settings`**
|
||||
event, putting browser-specific settings under a `sovereign_browser` namespace
|
||||
inside the same encrypted JSON. This aligns with the client and didactyl, and
|
||||
preserves privacy (no app-specific d-tag leaking which app the user runs).
|
||||
|
||||
**Target `d:user-settings` shape — `global` vs per-app:**
|
||||
|
||||
The `global` namespace holds **all shared settings** — things that should be
|
||||
the same across every app the user runs. This includes LLM provider
|
||||
infrastructure (under `global.agent`), and will grow to include zaps, UI
|
||||
preferences, relay lists, experimental flags, etc. Each app then has its own
|
||||
top-level namespace for app-specific config.
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 2,
|
||||
"updatedAt": 1708646400,
|
||||
|
||||
"global": {
|
||||
"agent": {
|
||||
"providers": [
|
||||
{
|
||||
"name": "ppq",
|
||||
"base_url": "https://api.ppq.ai",
|
||||
"api_key": "sk-...",
|
||||
"models": ["gpt-4o-mini", "claude-haiku-4.5", "claude-opus-4.6"]
|
||||
},
|
||||
{
|
||||
"name": "ollama-local",
|
||||
"base_url": "http://localhost:11434",
|
||||
"api_key": "",
|
||||
"models": ["llama3.1", "qwen2.5"]
|
||||
}
|
||||
],
|
||||
"favorites": ["gpt-4o-mini", "claude-opus-4.6"]
|
||||
},
|
||||
"zaps": {
|
||||
"defaultAmountSats": 21,
|
||||
"defaultComment": "",
|
||||
"preferredMethod": "auto"
|
||||
},
|
||||
"ui": {
|
||||
"theme": "dark",
|
||||
"language": "en"
|
||||
},
|
||||
"relays": { },
|
||||
"experimental": { }
|
||||
},
|
||||
|
||||
"sovereign_browser": {
|
||||
"agent": {
|
||||
"provider": "ppq",
|
||||
"model": "gpt-4o-mini",
|
||||
"system_prompt": "",
|
||||
"max_iterations": 100
|
||||
},
|
||||
"new_tab_url": "",
|
||||
"tab_bar_position": 0,
|
||||
"max_tabs": 50,
|
||||
"bootstrap_relays": "...",
|
||||
"search_engine": "duckduckgo",
|
||||
"theme_dark": false,
|
||||
"shortcuts": { ... }
|
||||
},
|
||||
|
||||
"client": {
|
||||
"ai": {
|
||||
"provider": "ppq",
|
||||
"model": "claude-opus-4.6",
|
||||
"routstr_balance": 0
|
||||
}
|
||||
},
|
||||
|
||||
"didactyl": {
|
||||
"agent": {
|
||||
"provider": "ppq",
|
||||
"model": "claude-haiku-4.5",
|
||||
"admin_pubkey": "npub1...",
|
||||
"dm_protocol": "nip04"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principle:** `global.agent.providers` is the shared catalog (API keys,
|
||||
base URLs, model lists). Each app's namespace (`sovereign_browser.agent`,
|
||||
`client.ai`, `didactyl.agent`) picks which provider+model to use, and holds
|
||||
app-specific agent config (system prompt, max_iterations, etc.). The `global`
|
||||
namespace will grow over time to include other shared settings (zaps, UI,
|
||||
relays). This way:
|
||||
- API keys are entered once, shared everywhere
|
||||
- The browser can use `gpt-4o-mini` while the client uses `claude-opus-4.6`
|
||||
- Each app's agent config is independent
|
||||
- Other shared settings (zaps, theme, etc.) live alongside `global.agent`
|
||||
- The `sovereign://agents` config page manages both the shared providers
|
||||
list AND the browser's per-app agent selection
|
||||
|
||||
**Plan:**
|
||||
1. **Migrate `settings_sync.c` from d-tag `"sovereign_browser"` to
|
||||
`"user-settings"`.** Change `SETTINGS_SYNC_D_TAG` to `"user-settings"`.
|
||||
Move the browser-specific settings into a `sovereign_browser` namespace
|
||||
inside the encrypted JSON (instead of top-level keys).
|
||||
|
||||
2. **Read `global_llm` from `d:user-settings` on startup.** After login,
|
||||
fetch the user's kind 30078 `d:user-settings` event, NIP-44 decrypt,
|
||||
parse `global_llm`, and populate `browser_settings_t` agent fields. Also
|
||||
parse the `sovereign_browser` namespace for browser settings.
|
||||
|
||||
3. **Read-modify-write on every settings change.** When any settings change
|
||||
(browser settings OR agent LLM config), do a read-modify-write:
|
||||
fetch the current event, decrypt, patch the relevant namespace
|
||||
(`global_llm` or `sovereign_browser`), re-encrypt, publish. This
|
||||
preserves other apps' namespaces (`global_zaps`, `global_ui`, etc.).
|
||||
|
||||
4. **Multi-provider support.** The `global_llm.providers` array allows
|
||||
multiple providers. The `sovereign://agents` config page should let the
|
||||
user manage multiple providers (add/remove/edit), select the active one,
|
||||
and fetch models per provider.
|
||||
|
||||
5. **Backward compatibility.** On first load after migration, if a
|
||||
`d:sovereign_browser` event exists but no `d:user-settings` does, read
|
||||
the old event and migrate the data into the new `d:user-settings` shape.
|
||||
Then publish the merged event and stop using the old d-tag.
|
||||
|
||||
**Modified files:**
|
||||
- [`src/settings_sync.h`](src/settings_sync.h) — Change
|
||||
`SETTINGS_SYNC_D_TAG` to `"user-settings"`.
|
||||
- [`src/settings_sync.c`](src/settings_sync.c) — Move browser settings into
|
||||
`sovereign_browser` namespace, add read-modify-write logic, add
|
||||
`global_llm` read/write, add backward-compat migration from old d-tag.
|
||||
- [`src/settings.c`](src/settings.c) — After `settings_load_user()`, call
|
||||
the sync load to fetch from Nostr.
|
||||
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_set()` should
|
||||
trigger a debounced sync publish.
|
||||
- [`src/settings.h`](src/settings.h) — Add multi-provider fields to
|
||||
`browser_settings_t` (providers array, active provider index).
|
||||
- [`src/relay_fetch.c`](src/relay_fetch.c) — Update the fetch filter to use
|
||||
`d:user-settings` instead of `d:sovereign_browser`.
|
||||
|
||||
---
|
||||
|
||||
## Workstream 3 — Port ai.html chat UI elements
|
||||
|
||||
### What ai.html has that we want
|
||||
|
||||
From [`~/lt/client/www/ai.html`](../client/www/ai.html):
|
||||
|
||||
1. **Chat layout** — Left pane with conversation list + skills list, right
|
||||
pane with chat thread + input area. See
|
||||
[`ai.html:44-80`](../client/www/ai.html:44) for the CSS layout.
|
||||
|
||||
2. **Conversation persistence to Nostr** — Each conversation is saved as a
|
||||
kind 30078 event with `d:{conversation-id}` and `t:client-ai-chat-v1`,
|
||||
NIP-44 encrypted. See
|
||||
[`ai.html:788`](../client/www/ai.html:788) and
|
||||
[`ai.html:1741`](../client/www/ai.html:1741).
|
||||
|
||||
3. **Skills (kind 31123)** — Public skills that define system prompts,
|
||||
LLM params, and tool requirements. Users can select multiple skills
|
||||
whose templates are concatenated into the system prompt. See
|
||||
[`~/lt/client/plans/ai-skills-integration.md`](../client/plans/ai-skills-integration.md).
|
||||
|
||||
4. **Provider config sidenav** — Provider dropdown, model dropdown, API key
|
||||
input, Routstr payment integration.
|
||||
|
||||
### What to port to sovereign://agents/chat
|
||||
|
||||
**Phase A — Chat layout:**
|
||||
- Two-pane layout: conversation list (left) + chat thread (right).
|
||||
- Conversation list shows saved conversations with titles, click to load.
|
||||
- "New Chat" button to start a new conversation.
|
||||
- Chat thread shows messages with role labels, tool call details collapsible.
|
||||
- Input area at the bottom with send button.
|
||||
|
||||
**Phase B — Conversation persistence to Nostr:**
|
||||
- Save each conversation as kind 30078, `d:{conversation-id}`,
|
||||
`t:sovereign-browser-ai-chat-v1`, NIP-44 encrypted.
|
||||
- Content: `{ schema: 1, messages: [...], title: "..." }`.
|
||||
- Load conversations from Nostr on page load.
|
||||
- Delete conversations (kind 5 tombstone).
|
||||
|
||||
**Phase C — Skills (kind 31123):**
|
||||
- Fetch public skills from Nostr (kind 31123 events).
|
||||
- Display skills in the left pane below conversations.
|
||||
- Selecting a skill applies its system prompt template.
|
||||
- Skills with `requires_tool` tags are highlighted (sovereign_browser HAS
|
||||
tools, so they're fully usable — unlike ai.html which has no tools).
|
||||
|
||||
**Phase D — Save skills/agents to Nostr:**
|
||||
- UI to create/edit skills (kind 31123) with system prompt, model, temp.
|
||||
- Publish to Nostr so they're shared across all three projects.
|
||||
|
||||
**Files to modify:**
|
||||
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — Major rewrite of
|
||||
`handle_agents_chat_page()` to include the two-pane layout, conversation
|
||||
list, skills list. New endpoints: `sovereign://agents/conversations`,
|
||||
`sovereign://agents/conversations/new`, `sovereign://agents/conversations/delete`,
|
||||
`sovereign://agents/skills`, `sovereign://agents/skills/save`.
|
||||
- [`src/agent_chat_store.c`](src/agent_chat_store.c) — Add functions to
|
||||
load/save conversations as Nostr events (via `relay_fetch.c`).
|
||||
- New: `src/agent_skills.h` / `src/agent_skills.c` — Fetch, parse, and
|
||||
apply kind 31123 skills.
|
||||
|
||||
---
|
||||
|
||||
## Cross-project harmony
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ User's Nostr │
|
||||
│ │
|
||||
│ d:user-settings (k30078) │
|
||||
│ └─ global_llm (NIP-44 enc) │
|
||||
│ │
|
||||
│ d:{convo-id} (k30078) │
|
||||
│ └─ conversations (NIP-44) │
|
||||
│ │
|
||||
│ kind 31123 (public skills) │
|
||||
└────────┬───────────┬────────────┘
|
||||
│ │
|
||||
┌───────────────────┼───────────┼───────────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ client │ │ sovereign_ │ │ sovereign_ │ │ didactyl │
|
||||
│ ai.html │ │ browser │ │ browser │ │ daemon │
|
||||
│ │ │ agents config │ │ agents/chat │ │ │
|
||||
│ read/write │ │ read/write │ │ read/write │ │ read/ │
|
||||
│ global_llm │ │ global_llm │ │ conversations│ │ write │
|
||||
│ read/write │ │ │ │ fetch/publish│ │ global │
|
||||
│ conversations │ │ │ │ skills │ │ _llm │
|
||||
│ publish/fetch │ │ │ │ │ │ fetch │
|
||||
│ skills │ │ │ │ │ │ skills │
|
||||
└─────────────────┘ └───────────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
All three projects share:
|
||||
- **`global_llm`** in `d:user-settings` — provider, API key, model
|
||||
- **Conversations** in `d:{convo-id}` — encrypted chat history
|
||||
- **Skills** in kind 31123 — public system prompt templates
|
||||
|
||||
sovereign_browser's unique advantage: it has **browser tools** (snapshot,
|
||||
click, eval) + **filesystem/shell tools** that ai.html and didactyl don't
|
||||
have. Skills with `requires_tool` tags are fully functional in
|
||||
sovereign_browser.
|
||||
|
||||
---
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1 (immediate):** Fix chat UI message rendering bug.
|
||||
|
||||
**Phase 2:** Kind 30078 `global_llm` sync — read from Nostr on startup,
|
||||
write on config change. Single-provider first.
|
||||
|
||||
**Phase 3:** Port ai.html chat layout — two-pane, conversation list,
|
||||
conversation persistence to Nostr.
|
||||
|
||||
**Phase 4:** Skills (kind 31123) — fetch, display, apply, create.
|
||||
|
||||
**Phase 5:** Multi-provider support in `global_llm`.
|
||||
320
plans/embedded-agent.md
Normal file
320
plans/embedded-agent.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Embedded Agent — In-Browser LLM with First-Class MCP Access
|
||||
|
||||
## Concurrency model — long-running tasks
|
||||
|
||||
The agent loop runs on a **background GThread**, not the GTK main thread. This
|
||||
is critical for long-running tasks like "follow each link, download images,
|
||||
write a summary for each" which may involve dozens of LLM calls and hundreds
|
||||
of tool calls.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Chat UI main thread
|
||||
participant Chat as agent_chat.c
|
||||
participant Thread as Agent Loop background thread
|
||||
participant LLM as LLM API
|
||||
participant Main as GTK main loop
|
||||
participant Tools as agent_tools_dispatch
|
||||
|
||||
UI->>Chat: POST sovereign://agents/send?text=...
|
||||
Chat->>Thread: g_thread_new agent_loop_run
|
||||
Chat-->>UI: 200 OK session started
|
||||
loop ReAct iterations
|
||||
Thread->>LLM: POST chat/completions blocking
|
||||
LLM-->>Thread: response + tool_calls
|
||||
Thread->>Thread: persist assistant message
|
||||
alt has tool_calls
|
||||
loop each tool call
|
||||
Thread->>Main: g_idle_add dispatch_tool
|
||||
Main->>Tools: agent_tools_dispatch sync JS eval
|
||||
Tools-->>Main: result
|
||||
Main-->>Thread: result via GAsyncQueue
|
||||
Thread->>Thread: persist tool result
|
||||
end
|
||||
else no tool_calls
|
||||
Thread->>Thread: mark session complete
|
||||
end
|
||||
end
|
||||
Thread->>Main: g_idle_add update_ui final
|
||||
```
|
||||
|
||||
### Threading rules
|
||||
|
||||
1. **LLM HTTP calls** happen on the background thread — `soup_session_send`
|
||||
blocks in-thread, the GTK UI stays responsive.
|
||||
|
||||
2. **Browser tool dispatch** (snapshot, click, eval, etc.) MUST run on the
|
||||
GTK main thread because WebKitGTK is not thread-safe. The background
|
||||
thread schedules each tool call via `g_idle_add()` and waits for the
|
||||
result on a `GAsyncQueue` or a per-call `GMainLoop` (same pattern the
|
||||
MCP HTTP handler uses for sync JS eval).
|
||||
|
||||
3. **Filesystem + shell tools** run directly on the background thread — they
|
||||
don't touch GTK or WebKit, so no main-thread hop needed. This keeps
|
||||
long shell commands from blocking the UI.
|
||||
|
||||
4. **SQLite writes** use the existing `SQLITE_OPEN_FULLMUTEX` connection
|
||||
(thread-safe). The background thread can call `db_kv_set` /
|
||||
`agent_chat_store_*` directly.
|
||||
|
||||
5. **UI updates** happen via `g_idle_add()` — the background thread pushes
|
||||
status updates (current tool, iteration count, partial output) to a
|
||||
shared struct, and an idle callback renders them in the chat page.
|
||||
|
||||
6. **Cancellation** — a `g_atomic_int` cancel flag is checked at the top of
|
||||
each loop iteration. The chat UI's "Stop" button sets it. The background
|
||||
thread exits cleanly at the next check point.
|
||||
|
||||
### Iteration cap
|
||||
|
||||
The default `max_iterations` is **100** (configurable on
|
||||
`sovereign://agents`), not 20. Long tasks like "follow 30 links" need
|
||||
multiple tool calls per link (open, snapshot, extract, download, write) —
|
||||
easily 100+ calls. The cap is a safety valve, not a tight limit. The user
|
||||
can raise it in settings for very long batch jobs.
|
||||
|
||||
### Status polling
|
||||
|
||||
The chat UI polls `sovereign://agents/status?session=...` every 500ms while
|
||||
a session is active. The response includes:
|
||||
- `state`: `idle` | `thinking` | `tool_call` | `complete` | `error` | `cancelled`
|
||||
- `iteration`: current iteration number
|
||||
- `current_tool`: name of the tool being executed (if any)
|
||||
- `last_message`: most recent assistant text (for progressive display)
|
||||
- `error`: error message if state is `error`
|
||||
|
||||
This gives the user live visibility into long-running tasks without
|
||||
streaming complexity.
|
||||
|
||||
## Goal
|
||||
|
||||
Embed an LLM-powered agent directly inside sovereign_browser. The user types
|
||||
`; <message>` in the URL bar to talk to the agent. The agent has first-class
|
||||
access to the browser's own MCP tool set (snapshot, click, eval, tabs, etc.)
|
||||
plus full filesystem and shell access (the browser runs in a dedicated Qubes
|
||||
qube, so arbitrary command execution is acceptable and intended).
|
||||
|
||||
Provider config (base URL, API key, model name) is managed on a new
|
||||
`sovereign://agents` internal page. Chat history is persisted per-session in
|
||||
SQLite so it can be fed back as context on follow-up messages.
|
||||
|
||||
## User-facing flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User types in URL bar] --> Check{Starts with semicolon?}
|
||||
Check -- yes --> Agent[Embedded Agent Chat UI]
|
||||
Check -- no --> Nav[Normal navigation]
|
||||
Agent --> LLM[OpenAI-compatible API call]
|
||||
LLM --> ToolLoop{Tool calls in response?}
|
||||
ToolLoop -- yes --> Dispatch[agent_tools_dispatch via internal MCP loopback]
|
||||
Dispatch --> ToolLoop
|
||||
ToolLoop -- no --> Render[Render assistant message in chat UI]
|
||||
Render --> User
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph URLBar
|
||||
Entry[GtkEntry on_url_activate]
|
||||
end
|
||||
subgraph AgentModule
|
||||
Router[agent_chat_route_input]
|
||||
Client[agent_llm.c — HTTP client]
|
||||
Loop[agent_loop.c — tool-call loop]
|
||||
Store[agent_chat_store.c — SQLite persistence]
|
||||
end
|
||||
subgraph BrowserCore
|
||||
Tools[agent_tools_dispatch]
|
||||
FsTools[agent_fs_tools.c — fs + shell tools]
|
||||
Bridge[nostr_bridge.c — sovereign:// scheme]
|
||||
end
|
||||
subgraph External
|
||||
API[OpenAI-compatible API]
|
||||
end
|
||||
subgraph UI
|
||||
ChatPage[sovereign://agents/chat — HTML/JS chat UI]
|
||||
ConfigPage[sovereign://agents — provider config]
|
||||
end
|
||||
|
||||
Entry --> Router
|
||||
Router -->|chat message| Client
|
||||
Client -->|HTTPS POST| API
|
||||
API -->|JSON response| Client
|
||||
Client --> Loop
|
||||
Loop -->|tool call| Tools
|
||||
Loop -->|fs/shell call| FsTools
|
||||
Tools --> BrowserCore
|
||||
Loop --> Store
|
||||
Store --> ChatPage
|
||||
Bridge --> ChatPage
|
||||
Bridge --> ConfigPage
|
||||
```
|
||||
|
||||
### Key design decisions
|
||||
|
||||
1. **Reuse `agent_tools_dispatch()`** — The embedded agent calls the exact same
|
||||
C dispatch function the external MCP server uses. No parallel tool
|
||||
implementation. The agent passes `conn = NULL` so async JS tools use the
|
||||
sync `agent_js_eval_sync()` path (same as the MCP HTTP handler).
|
||||
|
||||
2. **New system tools (`agent_fs_tools.c`)** — `fs_read`, `fs_write`,
|
||||
`fs_list`, `fs_mkdir`, `fs_delete`, `shell_exec`. These are registered
|
||||
alongside the existing browser tools so both the embedded agent and
|
||||
external MCP clients can use them. Full shell access — the Qubes qube
|
||||
provides the sandbox.
|
||||
|
||||
3. **OpenAI-compatible HTTP client (`agent_llm.c`)** — Uses libsoup-3.0
|
||||
(already linked) to POST to `{base_url}/chat/completions` with the
|
||||
`tools` array built from the same `tool_defs[]` catalog in
|
||||
[`agent_mcp.c`](src/agent_mcp.c:130). Streams or polls; parses tool calls
|
||||
from the response. One client covers OpenAI, OpenRouter, Ollama, LM Studio,
|
||||
Groq, etc. via base URL + key.
|
||||
|
||||
4. **Tool-call loop (`agent_loop.c`)** — Standard ReAct loop, runs on a
|
||||
background `GThread` (see Concurrency model above):
|
||||
- Build messages array (system prompt + persisted history + new user msg).
|
||||
- Call LLM (blocking HTTP on the background thread).
|
||||
- If response contains `tool_calls`, dispatch each:
|
||||
- Browser tools → hop to GTK main thread via `g_idle_add()` + wait on
|
||||
`GAsyncQueue` (WebKitGTK is not thread-safe).
|
||||
- Filesystem/shell tools → run directly on the background thread.
|
||||
- Append tool results to messages, call LLM again.
|
||||
- Repeat until no more tool calls → render final assistant text.
|
||||
- Cap at N iterations (default 100, configurable) to prevent infinite
|
||||
loops. Check cancel flag at the top of each iteration.
|
||||
|
||||
5. **Chat persistence (`agent_chat_store.c`)** — New SQLite tables
|
||||
(`agent_sessions`, `agent_messages`) in the per-user `browser.db`. Each
|
||||
session has an id, title, created_at. Messages store role
|
||||
(user/assistant/tool), content, and tool-call JSON. The URL-bar `;` command
|
||||
reuses the most recent session (or starts a new one if none exists).
|
||||
|
||||
6. **`sovereign://agents` config page** — Rendered by `nostr_bridge.c` (same
|
||||
pattern as `sovereign://settings`). Fields: provider name, base URL, API
|
||||
key, model name, system prompt (optional). Saved via
|
||||
`sovereign://agents/set?key=...&value=...` to the `key_value` table
|
||||
(existing `db_kv_set`). API key stored in the key_value table; since this
|
||||
is a dedicated qube, that's acceptable.
|
||||
|
||||
7. **`sovereign://agents/chat` chat UI** — A lightweight HTML/JS page
|
||||
(rendered by `nostr_bridge.c`) that:
|
||||
- Shows the message history (loaded from SQLite via a
|
||||
`sovereign://agents/messages?session=...` endpoint).
|
||||
- Has an input box for follow-up messages (POSTed to
|
||||
`sovereign://agents/send?session=...&text=...`).
|
||||
- Polls `sovereign://agents/status?session=...` for in-progress tool calls
|
||||
and streams the assistant's final response.
|
||||
- The URL-bar `;` shortcut navigates here with the message pre-filled and
|
||||
auto-submits.
|
||||
|
||||
8. **URL-bar `;` routing** — In [`on_url_activate`](src/tab_manager.c:913),
|
||||
check if `text[0] == ';'`. If so, extract the message (`text + 1`, trimmed)
|
||||
and call `agent_chat_route_input(message)` instead of `normalize_url()`.
|
||||
The function opens `sovereign://agents/chat` in the active tab (or a new
|
||||
tab if the active tab isn't already the chat page) and kicks off the
|
||||
agent loop. If the message is empty (`;` alone), just open the chat page
|
||||
without sending.
|
||||
|
||||
9. **System prompt** — A default system prompt explains the agent's
|
||||
capabilities: it controls a web browser via MCP tools, has filesystem and
|
||||
shell access, and should use the snapshot+ref pattern for page
|
||||
interaction. The user can override this on `sovereign://agents`.
|
||||
|
||||
## New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/agent_llm.h` / `src/agent_llm.c` | OpenAI-compatible HTTP client (libsoup). Sends chat-completions request with tools, parses response + tool_calls. |
|
||||
| `src/agent_loop.h` / `src/agent_loop.c` | ReAct tool-call loop. Orchestrates LLM calls ↔ tool dispatch. |
|
||||
| `src/agent_chat_store.h` / `src/agent_chat_store.c` | SQLite persistence for chat sessions and messages. |
|
||||
| `src/agent_fs_tools.h` / `src/agent_fs_tools.c` | Filesystem + shell tools (fs_read, fs_write, fs_list, fs_mkdir, fs_delete, shell_exec). |
|
||||
| `src/agent_chat.h` / `src/agent_chat.c` | High-level entry point: `agent_chat_route_input()`, session management, bridges URL-bar → loop → UI. |
|
||||
|
||||
## Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:913) | `on_url_activate`: detect `;` prefix, route to `agent_chat_route_input()`. |
|
||||
| [`src/nostr_bridge.c`](src/nostr_bridge.c:1723) | Add `sovereign://agents`, `sovereign://agents/chat`, `sovereign://agents/set`, `sovereign://agents/messages`, `sovereign://agents/send`, `sovereign://agents/status` route handlers. |
|
||||
| [`src/agent_mcp.c`](src/agent_mcp.c:130) | Export `tool_defs[]` / `build_tools_list()` (or move to a shared header) so `agent_llm.c` can build the OpenAI tools array from the same catalog. Add fs/shell tool defs. |
|
||||
| [`src/agent_tools.c`](src/agent_tools.c) | Dispatch fs/shell tools (or route them via a new `agent_fs_tools_dispatch()` called from the same dispatcher). |
|
||||
| [`src/db.c`](src/db.c) / [`src/db.h`](src/db.h) | Add `agent_sessions` + `agent_messages` tables and CRUD functions. |
|
||||
| [`src/settings.h`](src/settings.h) / [`src/settings.c`](src/settings.c) | Add agent provider settings fields (base_url, api_key, model, system_prompt). |
|
||||
| [`Makefile`](Makefile) | Add new `.c` files to `SRC`. |
|
||||
|
||||
## SQLite schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
title TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
role TEXT, -- 'user' | 'assistant' | 'tool' | 'system'
|
||||
content TEXT, -- message text (or tool result JSON)
|
||||
tool_calls TEXT, -- JSON array of tool calls (assistant msgs)
|
||||
tool_call_id TEXT, -- for role='tool': which call this answers
|
||||
created_at INTEGER,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
|
||||
ON agent_messages(session_id, created_at);
|
||||
```
|
||||
|
||||
## Tool catalog additions (fs + shell)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fs_read` | Read a file's contents (text). Params: `path`. |
|
||||
| `fs_write` | Write text to a file (overwrite). Params: `path`, `content`. |
|
||||
| `fs_list` | List directory entries. Params: `path`. |
|
||||
| `fs_mkdir` | Create a directory (recursive). Params: `path`. |
|
||||
| `fs_delete` | Delete a file or empty directory. Params: `path`. |
|
||||
| `shell_exec` | Run a shell command, return stdout+stderr+exit code. Params: `command`, `timeout_ms` (default 30000). |
|
||||
|
||||
## OpenAI tools array format
|
||||
|
||||
The `agent_llm.c` client builds the `tools` field from the shared
|
||||
`tool_defs[]` array, converting each entry to the OpenAI format:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "snapshot",
|
||||
"description": "Get the accessibility tree...",
|
||||
"parameters": { ...schema... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Concurrency note
|
||||
|
||||
See the **Concurrency model** section at the top of this document for the
|
||||
full design. Summary: the agent loop runs on a background `GThread`. LLM
|
||||
HTTP calls block in-thread. Browser tool calls hop to the GTK main thread
|
||||
via `g_idle_add()` + `GAsyncQueue` (WebKitGTK is not thread-safe).
|
||||
Filesystem and shell tools run directly on the background thread. The UI
|
||||
polls `sovereign://agents/status` for live progress. A cancel flag allows
|
||||
the user to stop long-running tasks.
|
||||
|
||||
## Phasing
|
||||
|
||||
The work breaks into two phases that can be implemented sequentially:
|
||||
|
||||
**Phase 1 — Foundation:** fs/shell tools, LLM client, tool-call loop, chat
|
||||
persistence, `sovereign://agents` config page, URL-bar `;` routing, basic
|
||||
chat UI. After Phase 1 the example task ("save links to ~/temp/links.txt")
|
||||
works end-to-end.
|
||||
|
||||
**Phase 2 — Polish:** streaming responses, tool-call progress display in the
|
||||
chat UI, session list / history sidebar, editable system prompt per session,
|
||||
multi-session support from the URL bar, error recovery UI.
|
||||
179
plans/embedded-web-content.md
Normal file
179
plans/embedded-web-content.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Embedded Web Content — sovereign:// pages from files
|
||||
|
||||
## Problem
|
||||
|
||||
The `sovereign://agents/chat` page is hardcoded as a ~800-line C string literal
|
||||
in [`src/nostr_bridge.c`](src/nostr_bridge.c) (`handle_agents_chat_page()`).
|
||||
This causes:
|
||||
- **Bugs** — C string escaping errors (`\"`, `\\`, `%%`) are easy to make and
|
||||
hard to spot. The current "messages don't render" and "New Chat doesn't work"
|
||||
bugs are likely caused by this.
|
||||
- **Maintenance nightmare** — No syntax highlighting, no IDE support, no
|
||||
formatting tools work on the embedded HTML/CSS/JS.
|
||||
- **Can't copy ai.html** — The user wants to port `~/lt/client/www/ai.html`'s
|
||||
structure, but that's a standalone HTML file with separate CSS/JS. Embedding
|
||||
it as a C string would be thousands of lines of escaped strings.
|
||||
|
||||
## Solution: Copy c-relay's approach
|
||||
|
||||
`~/lt/c-relay` solves this with a build-time embedding script:
|
||||
|
||||
1. **Author web files as normal files** in a `www/` directory (HTML, CSS, JS —
|
||||
no escaping needed, full IDE support).
|
||||
2. **`embed_web_files.sh`** — A shell script that runs at build time. It uses
|
||||
`hexdump` to convert each file into a C byte array
|
||||
(`0x3c,0x21,0x44,...`) in an auto-generated `src/embedded_web_content.c`.
|
||||
3. **`embedded_web_content.h`** — Declares `embedded_file_t` and
|
||||
`get_embedded_file(path)`.
|
||||
4. **`embedded_web_content.c`** — Auto-generated byte arrays + a lookup table
|
||||
mapping paths to arrays.
|
||||
5. **The `sovereign://` handler** calls `get_embedded_file()` to serve the
|
||||
embedded content instead of building HTML with `g_strdup_printf()`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ www/ (normal files, edit freely) │
|
||||
│ agents/chat.html (standalone HTML) │
|
||||
│ agents/chat.css (separate CSS) │
|
||||
│ agents/chat.js (separate JS) │
|
||||
│ agents/config.html (provider config page) │
|
||||
│ agents/config.css │
|
||||
│ agents/config.js │
|
||||
│ settings.html │
|
||||
│ bookmarks.html │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ embed_web_files.sh (build time)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ src/embedded_web_content.c (auto-generated, do not edit) │
|
||||
│ static const unsigned char agents_chat_html_data[] = { │
|
||||
│ 0x3c,0x21,0x44,0x4f,0x43,0x54,0x59,0x50,0x45,... │
|
||||
│ }; │
|
||||
│ static const size_t agents_chat_html_size = 12345; │
|
||||
│ ... │
|
||||
│ static embedded_file_t embedded_files[] = { │
|
||||
│ {"agents/chat.html", agents_chat_html_data, ...}, │
|
||||
│ {"agents/chat.css", agents_chat_css_data, ...}, │
|
||||
│ {"agents/chat.js", agents_chat_js_data, ...}, │
|
||||
│ {NULL, NULL, 0, NULL} │
|
||||
│ }; │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ get_embedded_file(path)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ src/nostr_bridge.c (sovereign:// handler) │
|
||||
│ on_sovereign_scheme(): │
|
||||
│ if (strncmp(uri, "sovereign://agents/chat", ...) == 0) │
|
||||
│ serve_embedded_file(request, "agents/chat.html"); │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 1 — Set up the embedding infrastructure
|
||||
|
||||
1. **Create `www/` directory** at the project root for web files.
|
||||
|
||||
2. **Create `embed_web_files.sh`** (copy from c-relay, adapt for our file
|
||||
structure). It scans `www/**/*.html`, `www/**/*.css`, `www/**/*.js` and
|
||||
generates `src/embedded_web_content.c` + `src/embedded_web_content.h`.
|
||||
|
||||
3. **Create `src/embedded_web_content.h`**:
|
||||
```c
|
||||
typedef struct {
|
||||
const char *path; /* e.g. "agents/chat.html" */
|
||||
const unsigned char *data;
|
||||
size_t size;
|
||||
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
|
||||
} embedded_file_t;
|
||||
|
||||
const embedded_file_t *get_embedded_file(const char *path);
|
||||
```
|
||||
|
||||
4. **Add a `serve_embedded_file()` helper** in `nostr_bridge.c` that calls
|
||||
`get_embedded_file()` and responds with the bytes via `respond_bytes()`.
|
||||
|
||||
5. **Update `Makefile`** to run `embed_web_files.sh` before compiling, and
|
||||
add `src/embedded_web_content.c` to `SRC`.
|
||||
|
||||
6. **Update `.gitignore`** to ignore `src/embedded_web_content.c` and
|
||||
`src/embedded_web_content.h` (they're auto-generated).
|
||||
|
||||
### Phase 2 — Migrate the chat page to files
|
||||
|
||||
1. **Create `www/agents/chat.html`** — Extract the HTML structure from
|
||||
`handle_agents_chat_page()` into a standalone HTML file. No C string
|
||||
escaping needed.
|
||||
|
||||
2. **Create `www/agents/chat.css`** — Extract the CSS into a separate file.
|
||||
Link it from the HTML: `<link rel="stylesheet" href="sovereign://agents/chat.css">`.
|
||||
|
||||
3. **Create `www/agents/chat.js`** — Extract the JavaScript into a separate
|
||||
file. Link it: `<script src="sovereign://agents/chat.js"></script>`.
|
||||
|
||||
4. **Add routes** in `on_sovereign_scheme()`:
|
||||
- `sovereign://agents/chat` → serve `agents/chat.html`
|
||||
- `sovereign://agents/chat.css` → serve `agents/chat.css`
|
||||
- `sovereign://agents/chat.js` → serve `agents/chat.js`
|
||||
|
||||
5. **Fix the bugs** while extracting:
|
||||
- **Messages not rendering:** The `renderMessage()` function must include
|
||||
`data-msg-index="N"` on `.msg-bubble-content` and `.msg-bubble-menu-host`
|
||||
elements so `renderBubbleContent()` and `mountDotMenu()` can find them.
|
||||
- **New Chat not working:** Verify `sovereign://agents/conversations/new`
|
||||
creates a new session and sets it as active. The `newChat()` JS must
|
||||
clear the message list and reset `lastCount = -1`.
|
||||
|
||||
### Phase 3 — Migrate other sovereign:// pages
|
||||
|
||||
Once the chat page works, migrate the other pages the same way:
|
||||
- `sovereign://agents` (config page) → `www/agents/config.html` + `.css` + `.js`
|
||||
- `sovereign://settings` → `www/settings.html` + `.css` + `.js`
|
||||
- `sovereign://bookmarks` → `www/bookmarks.html` + `.css` + `.js`
|
||||
- `sovereign://profile` → `www/profile.html` + `.css` + `.js`
|
||||
|
||||
Each page keeps its C-side data endpoints (`sovereign://agents/set`,
|
||||
`sovereign://agents/messages`, etc.) but the page HTML/CSS/JS moves to files.
|
||||
|
||||
### Phase 4 — Port ai.html features
|
||||
|
||||
With the chat page as a standalone HTML file, we can directly copy:
|
||||
- The ai.html message bubble CSS (from `messaging-ui.css`)
|
||||
- The dot-menu CSS (from `dot-menu.css`)
|
||||
- The markdown renderer (use `marked.js` from the client's vendor libs, or
|
||||
keep our minimal renderer)
|
||||
- The conversation list layout
|
||||
- The skills list layout
|
||||
- The provider config sidenav
|
||||
|
||||
## Benefits
|
||||
|
||||
- **No C string escaping** — Edit HTML/CSS/JS normally with full IDE support
|
||||
- **Syntax highlighting** — IDEs recognize `.html`, `.css`, `.js` files
|
||||
- **Can copy ai.html directly** — Just copy the relevant sections
|
||||
- **Smaller `nostr_bridge.c`** — The 800-line chat page string literal is gone
|
||||
- **Easier debugging** — Browser dev tools show the actual HTML, not escaped strings
|
||||
- **Build-time embedding** — Files are bundled in the binary, no external files needed at runtime
|
||||
|
||||
## Files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `embed_web_files.sh` | Build script: converts www/ files to C byte arrays |
|
||||
| `src/embedded_web_content.h` | Header for the embedded file lookup |
|
||||
| `src/embedded_web_content.c` | Auto-generated byte arrays (gitignored) |
|
||||
| `www/agents/chat.html` | Chat page HTML |
|
||||
| `www/agents/chat.css` | Chat page CSS |
|
||||
| `www/agents/chat.js` | Chat page JS |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Makefile` | Run embed script, add embedded_web_content.c to SRC |
|
||||
| `.gitignore` | Ignore auto-generated embedded_web_content.* |
|
||||
| `src/nostr_bridge.c` | Add serve_embedded_file(), route to embedded files, remove old handle_agents_chat_page() string literal |
|
||||
465
plans/fips-management-page.md
Normal file
465
plans/fips-management-page.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# FIPS Management Page — `sovereign://fips`
|
||||
|
||||
## Goal
|
||||
|
||||
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
|
||||
inside the browser. This is **not** part of the settings page — it's a
|
||||
separate status + management page, like a network control panel.
|
||||
|
||||
The page lets the user:
|
||||
|
||||
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
|
||||
tree state, ownership (managed vs attached), error messages.
|
||||
2. **See connected peers** — list of peers with their npub, address,
|
||||
transport, and connection state.
|
||||
3. **Connect to a peer** — add a new peer by npub + address + transport.
|
||||
4. **Disconnect a peer** — remove a peer connection.
|
||||
5. **Restart the FIPS service** — stop + start the managed daemon, or
|
||||
re-attach.
|
||||
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
|
||||
services, showing both on one "Network" page makes sense. Tor is
|
||||
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
|
||||
interactive management.
|
||||
|
||||
This makes `.fips` addresses actually usable: the user can add peers from
|
||||
the browser UI instead of dropping to `fipsctl` on the command line.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
|
||||
|
||||
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
|
||||
The browser already has a client:
|
||||
|
||||
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
|
||||
connection to the socket.
|
||||
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
|
||||
`{"command":"show_status"}`, parses response into `fips_status_t`
|
||||
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
|
||||
tun_active).
|
||||
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
|
||||
`{"command":"show_peers"}`, returns the `data` field as a JSON string
|
||||
(caller frees). **This is not currently called anywhere** — it exists
|
||||
but is unused.
|
||||
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
|
||||
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
|
||||
**Also unused.**
|
||||
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
|
||||
|
||||
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
|
||||
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
|
||||
needs verification against the FIPS daemon's control protocol — see
|
||||
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
|
||||
`fips_control_disconnect_peer()` function.
|
||||
|
||||
### Net services layer (`src/net_services.c` / `src/net_services.h`)
|
||||
|
||||
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
|
||||
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
|
||||
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
|
||||
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
|
||||
`control_socket[512]`.
|
||||
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
|
||||
(managed) or 5s (attached) and updates the struct. It does **not** poll
|
||||
`show_peers` — only the peer *count* is tracked.
|
||||
- The service `state` field (`service_state_t`) tracks the lifecycle:
|
||||
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
|
||||
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
|
||||
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
|
||||
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
|
||||
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
|
||||
it disables then re-enables the service.
|
||||
|
||||
### sovereign:// page pattern (`src/nostr_bridge.c`)
|
||||
|
||||
The existing pages (settings, profile, bookmarks, agents) follow this
|
||||
pattern:
|
||||
|
||||
1. **HTML page** served from an embedded `www/` file via
|
||||
[`serve_embedded_file()`](src/nostr_bridge.c:132).
|
||||
2. **CSS** served from `www/<name>.css`, linked via
|
||||
`<link href="sovereign://fips.css">`.
|
||||
3. **JS** served from `www/<name>.js`, loaded via
|
||||
`<script src="sovereign://fips.js">`.
|
||||
4. **JSON data endpoints** — `sovereign://fips/status` returns JSON,
|
||||
fetched by the JS on page load and on refresh.
|
||||
5. **Action endpoints** — `sovereign://fips/connect?npub=...&address=...&transport=...`
|
||||
performs an action and returns JSON.
|
||||
|
||||
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
|
||||
scheme handler doesn't support `fetch()` reliably for `sovereign://`
|
||||
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
|
||||
A `sovereignGet()` helper wraps XHR and returns a Promise.
|
||||
|
||||
Routing is in the main `bridge_handle_request()` function
|
||||
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
|
||||
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Status ──────────────────────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Node: npub1abc...xyz │ │
|
||||
│ │ TUN: fips0 (active) │ │
|
||||
│ │ Peers: 3 connected │ │
|
||||
│ │ Tree: healthy │ │
|
||||
│ │ Ownership: managed (spawned by browser) │ │
|
||||
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
|
||||
│ │ [Restart Service] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Peers ───────────────────────────────────────────────┐ │
|
||||
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
|
||||
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
|
||||
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
|
||||
│ │ npub: [npub1... ] │ │
|
||||
│ │ address: [host:port ] │ │
|
||||
│ │ transport: [udp ▼] │ │
|
||||
│ │ [Connect] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Bootstrap: 100% │ │
|
||||
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
|
||||
│ │ Ownership: attached (system Tor) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Routing
|
||||
|
||||
| URI | Handler | Returns |
|
||||
|-----|---------|---------|
|
||||
| `sovereign://fips` | serve `www/fips.html` | HTML page |
|
||||
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
|
||||
| `sovereign://fips.js` | serve `www/fips.js` | JS |
|
||||
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
|
||||
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
|
||||
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
|
||||
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
|
||||
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
|
||||
|
||||
### JSON endpoints
|
||||
|
||||
#### `sovereign://fips/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"fips": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "managed",
|
||||
"node_npub": "npub1abc...",
|
||||
"peer_count": 3,
|
||||
"tun_active": true,
|
||||
"tun_name": "fips0",
|
||||
"tree_state": "healthy",
|
||||
"daemon_state": "running",
|
||||
"control_socket": "~/.sovereign_browser/fips/control.sock",
|
||||
"error": null
|
||||
},
|
||||
"tor": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "attached",
|
||||
"bootstrap_progress": 100,
|
||||
"socks_endpoint": "socks5://127.0.0.1:9050",
|
||||
"circuit_info": "",
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a service is `DISABLED` or `FAILED`, the fields are populated as
|
||||
best as possible and `error` holds the failure message.
|
||||
|
||||
#### `sovereign://fips/peers`
|
||||
|
||||
Returns the raw `show_peers` data from the FIPS daemon (passed through
|
||||
as-is, since the format is defined by FIPS):
|
||||
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
|
||||
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
|
||||
|
||||
#### `sovereign://fips/connect`
|
||||
|
||||
Query params: `npub`, `address`, `transport` (optional, defaults to
|
||||
`udp`).
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/disconnect`
|
||||
|
||||
Query params: `npub`.
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/restart`
|
||||
|
||||
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
|
||||
`{"status": "ok"}` (the restart is async — the JS polls
|
||||
`sovereign://fips/status` to see when it's ready again).
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
|
||||
|
||||
```c
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
```
|
||||
|
||||
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
|
||||
exact command name** against the FIPS daemon's control protocol — check
|
||||
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
|
||||
`disconnect_peer` or `remove_peer`, adjust accordingly.
|
||||
|
||||
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
|
||||
|
||||
New functions (following the existing handler pattern):
|
||||
|
||||
- `handle_fips_status_json(request)` — reads
|
||||
`net_service_get_status(NET_SERVICE_FIPS)` and
|
||||
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
|
||||
the fields above, responds as JSON.
|
||||
- `handle_fips_peers_json(request)` — opens a control connection to the
|
||||
FIPS socket (from `status.fips.control_socket`), calls
|
||||
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
|
||||
responds as JSON. If FIPS is not ready, returns empty peers + error.
|
||||
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
|
||||
`transport` from query string, opens a control connection, calls
|
||||
`fips_control_connect_peer()`, responds as JSON.
|
||||
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
|
||||
control connection, calls `fips_control_disconnect_peer()`, responds
|
||||
as JSON.
|
||||
- `handle_fips_restart(request)` — calls
|
||||
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
|
||||
|
||||
**Control connection management:** Each action handler opens a fresh
|
||||
control connection to the FIPS socket, sends the command, closes the
|
||||
connection. This is simpler than reusing the `net_service_t`'s
|
||||
`control_fd` (which is owned by the poll callback and may be in use).
|
||||
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
|
||||
|
||||
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
|
||||
|
||||
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
|
||||
line 3606), following the same pattern as the settings/bookmarks/agents
|
||||
blocks:
|
||||
|
||||
```c
|
||||
/* Route sovereign://fips — FIPS mesh management page. */
|
||||
if (strcmp(uri, "sovereign://fips") == 0 ||
|
||||
strncmp(uri, "sovereign://fips?", 18) == 0) {
|
||||
serve_embedded_file(request, "fips.html");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.css") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
|
||||
serve_embedded_file(request, "fips.css");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.js") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
|
||||
serve_embedded_file(request, "fips.js");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/status") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
|
||||
handle_fips_status_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
|
||||
handle_fips_peers_json(request);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
|
||||
handle_fips_connect(request, uri + 24);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
|
||||
handle_fips_disconnect(request, uri + 27);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
|
||||
handle_fips_restart(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create `www/fips.html`
|
||||
|
||||
The HTML page. Links `fips.css` and `fips.js`. Structure:
|
||||
|
||||
- Status section (populated by JS from `sovereign://fips/status`)
|
||||
- Peers table (populated by JS from `sovereign://fips/peers`)
|
||||
- Add peer form (npub, address, transport dropdown, Connect button)
|
||||
- Tor status section (read-only, from the same status JSON)
|
||||
- Refresh button (re-fetches status + peers)
|
||||
|
||||
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
|
||||
`sovereign-base.css` for theme variables, monospace font, dark theme.
|
||||
|
||||
### 5. Create `www/fips.css`
|
||||
|
||||
Page-specific styles. Import the base theme:
|
||||
```css
|
||||
@import "sovereign://sovereign-base.css";
|
||||
```
|
||||
|
||||
Style the status cards, peers table, add-peer form. Match the existing
|
||||
sovereign:// page aesthetic.
|
||||
|
||||
### 6. Create `www/fips.js`
|
||||
|
||||
The page logic:
|
||||
|
||||
```javascript
|
||||
/* On load: fetch status + peers, populate DOM.
|
||||
* Refresh button: re-fetch.
|
||||
* Connect form: POST to sovereign://fips/connect, then refresh peers.
|
||||
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
|
||||
* Restart button: GET sovereign://fips/restart, then poll status.
|
||||
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
|
||||
*/
|
||||
```
|
||||
|
||||
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
|
||||
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
|
||||
|
||||
### 7. Add a link to the FIPS page from the settings page
|
||||
|
||||
In `www/settings.html` (or `www/settings.js`), add a link:
|
||||
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
|
||||
section. This makes the page discoverable.
|
||||
|
||||
### 8. No Makefile changes needed
|
||||
|
||||
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
|
||||
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
|
||||
sufficient — no Makefile edit required.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
www/
|
||||
├── fips.html # FIPS management page (new)
|
||||
├── fips.css # Page styles (new)
|
||||
├── fips.js # Page logic (new)
|
||||
src/
|
||||
├── nostr_bridge.c # Add FIPS route handlers + routing
|
||||
├── fips_control.c # Add fips_control_disconnect_peer()
|
||||
├── fips_control.h # Add disconnect_peer() declaration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Status page (read-only)
|
||||
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
|
||||
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
|
||||
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
|
||||
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
|
||||
5. Show Tor status (read-only)
|
||||
6. Auto-refresh every 5s
|
||||
7. Add link from settings page
|
||||
|
||||
### Phase 2: Peer management (interactive)
|
||||
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
|
||||
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
|
||||
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
|
||||
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
|
||||
5. Add peers table + add-peer form + disconnect buttons to the page
|
||||
6. Test: connect to a known FIPS peer, verify it appears in the list,
|
||||
verify `.fips` URLs to that peer resolve in the browser
|
||||
|
||||
### Phase 3: Service control
|
||||
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
|
||||
2. Add restart button to the page
|
||||
3. Poll status during restart (state transitions: READY → STOPPING →
|
||||
DISCOVERING → STARTING → READY)
|
||||
4. Show error messages when state is FAILED
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Copy-to-clipboard for node npub
|
||||
2. QR code for node npub (reuse `sovereign://qr?text=...`)
|
||||
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
|
||||
4. Transport auto-detect (try udp, then tcp)
|
||||
5. Address book — save known peers in SQLite for re-connecting after
|
||||
restart
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build and start browser
|
||||
2. Navigate to `sovereign://fips`
|
||||
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
|
||||
4. Verify Tor status shows correctly
|
||||
5. Verify auto-refresh updates peer count if peers change
|
||||
6. If FIPS is not running:
|
||||
- Verify status shows "disabled" or "failed" with error message
|
||||
- Verify peers list is empty with "FIPS not ready" message
|
||||
7. Add a peer:
|
||||
- Enter a known FIPS peer's npub + address + transport
|
||||
- Click Connect
|
||||
- Verify peer appears in the peers list
|
||||
- Verify `http://<peer-npub>.fips/` loads in a tab
|
||||
8. Disconnect a peer:
|
||||
- Click the ✕ next to a peer
|
||||
- Verify peer disappears from the list
|
||||
9. Restart FIPS:
|
||||
- Click Restart Service
|
||||
- Verify state transitions through stopping → starting → ready
|
||||
- Verify peers reconnect (if persistent) or need re-adding
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **FIPS disconnect command name** — is it `disconnect`,
|
||||
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
|
||||
control protocol docs or `fipsctl` source. The `connect` command is
|
||||
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
|
||||
assumed.
|
||||
|
||||
2. **Peer persistence** — when the browser restarts the managed FIPS
|
||||
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
|
||||
does the user need to re-add them every time? If non-persistent,
|
||||
Phase 4's "address book" (saving peers in SQLite) becomes more
|
||||
important.
|
||||
|
||||
3. **FIPS daemon control protocol docs** — the `show_peers` response
|
||||
format is passed through as-is. We should verify the field names
|
||||
(`npub`, `address`, `transport`, `state`) match what the JS expects.
|
||||
If the format differs, the JS can adapt, but it's better to know
|
||||
upfront.
|
||||
255
plans/fips-network-view.md
Normal file
255
plans/fips-network-view.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# FIPS Network View — `sovereign://fips/network`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the FIPS management page with a "Network" view that shows the
|
||||
**entire mesh** as the local node knows it — not just direct peers, but
|
||||
every node the daemon has discovered via bloom-filter propagation and
|
||||
Nostr discovery. This answers "what can you see on FIPS?" and "can you
|
||||
get a view of the entire network?"
|
||||
|
||||
## What the FIPS daemon exposes
|
||||
|
||||
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
|
||||
these read-only commands relevant to a network view:
|
||||
|
||||
| Command | What it returns | Network-view use |
|
||||
|---------|----------------|------------------|
|
||||
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
|
||||
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
|
||||
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
|
||||
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
|
||||
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
|
||||
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
|
||||
|
||||
### Key insight: "the entire network" = identity cache + tree
|
||||
|
||||
The FIPS daemon does **not** have a "ask peer X what peers it has"
|
||||
command. The mesh is observed through two mechanisms:
|
||||
|
||||
1. **Bloom filters** — each node broadcasts a bloom filter of node
|
||||
addresses it knows about. The local node unions these to estimate
|
||||
total mesh size (`estimated_mesh_size`) and caches individual node
|
||||
identities it has resolved (`show_identity_cache`).
|
||||
|
||||
2. **Spanning tree** — each node declares a parent; the tree
|
||||
coordinates encode paths to the root. `show_tree` gives the local
|
||||
node's tree position and its neighbors' positions.
|
||||
|
||||
So "the entire network" as seen from this node is:
|
||||
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
|
||||
last-seen). This is the node list for the network view.
|
||||
- **`show_tree`** → the local tree topology (who is my parent, who are
|
||||
my children, what is the root, what is the depth).
|
||||
- **`show_status.estimated_mesh_size`** → a single estimated total.
|
||||
|
||||
We **cannot** build a full graph of who-connects-to-whom from the local
|
||||
node's control socket alone — that would require every node to expose
|
||||
its peer list, and FIPS doesn't do that (bloom filters propagate
|
||||
*existence*, not *adjacency*). What we can show is:
|
||||
|
||||
- A **node list** (identity cache) — "these are all the nodes in the
|
||||
mesh that this node knows about."
|
||||
- A **tree view** — "this is my position in the spanning tree, my
|
||||
parent, my children, the root."
|
||||
- A **mesh summary** — "estimated N nodes total, tree depth D."
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
Add a "Network" tab/section to `sovereign://fips` (or a separate
|
||||
`sovereign://fips/network` page linked from the main FIPS page).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
│ [Status] [Peers] [Network] [Tree] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
|
||||
│ │ Estimated mesh size: 47 nodes │ │
|
||||
│ │ Known nodes (cache): 23 │ │
|
||||
│ │ Tree depth: 4 │ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our position: depth 2, parent npub1def... │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
|
||||
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
|
||||
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
|
||||
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
|
||||
│ │ ... │ │
|
||||
│ │ (● = direct peer, ○ = known via discovery only) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our parent: npub1def... (fips.v0l.io) │ │
|
||||
│ │ Our children: │ │
|
||||
│ │ npub1jkl... depth 3 distance 1 │ │
|
||||
│ │ npub1mno... depth 3 distance 1 │ │
|
||||
│ │ Tree peers (1-hop): │ │
|
||||
│ │ npub1abc... depth 0 root distance 2 │ │
|
||||
│ │ npub1def... depth 1 parent distance 1 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### New JSON endpoints
|
||||
|
||||
| URI | FIPS command | Returns |
|
||||
|-----|-------------|---------|
|
||||
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
|
||||
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
|
||||
|
||||
#### `sovereign://fips/network`
|
||||
|
||||
Calls three FIPS commands on a fresh control connection and combines
|
||||
the results:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"estimated_mesh_size": 47,
|
||||
"known_nodes": 23,
|
||||
"max_cache_entries": 256,
|
||||
"tree_depth": 4,
|
||||
"is_root": false,
|
||||
"root_npub": "npub1abc...",
|
||||
"root_display_name": "laantungir",
|
||||
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
|
||||
"our_parent": "npub1def...",
|
||||
"our_parent_display_name": "fips.v0l.io"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_addr": "e3da8293...",
|
||||
"npub": "npub1abc...",
|
||||
"display_name": "laantungir",
|
||||
"ipv6_addr": "fde3:da82:...",
|
||||
"last_seen_ms": 1784309108403,
|
||||
"age_ms": 2000,
|
||||
"is_direct_peer": true
|
||||
}
|
||||
],
|
||||
"tree": {
|
||||
"root": "1b4788b7...",
|
||||
"root_npub": "npub1abc...",
|
||||
"is_root": false,
|
||||
"depth": 2,
|
||||
"parent": "9d1192e8...",
|
||||
"parent_display_name": "fips.v0l.io",
|
||||
"peers": [
|
||||
{
|
||||
"node_addr": "1b4788b7...",
|
||||
"display_name": "laantungir",
|
||||
"depth": 0,
|
||||
"distance_to_us": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `is_direct_peer` flag on each node is computed by cross-referencing
|
||||
the identity cache entries against the `show_peers` npub list (so the
|
||||
UI can mark direct peers vs. discovery-only nodes).
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
|
||||
|
||||
These follow the existing `fips_control_show_peers()` pattern — send
|
||||
the command, return the `data` field as a JSON string (caller frees):
|
||||
|
||||
```c
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
```
|
||||
|
||||
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
|
||||
|
||||
Opens a control connection, calls `show_status`, `show_peers`,
|
||||
`show_identity_cache`, and `show_tree`, then combines them into the
|
||||
JSON above. The `is_direct_peer` flag is computed by building a set of
|
||||
direct-peer npubs from `show_peers` and checking each identity-cache
|
||||
entry against it.
|
||||
|
||||
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
|
||||
|
||||
```c
|
||||
if (strcmp(uri, "sovereign://fips/network") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
|
||||
handle_fips_network_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
|
||||
handle_fips_tree_json(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
|
||||
|
||||
Add tab navigation (Status | Peers | Network | Tree) to the page. The
|
||||
Network tab fetches `sovereign://fips/network` and renders:
|
||||
- Mesh summary card (estimated size, known nodes, depth, root, our
|
||||
position).
|
||||
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
|
||||
- Spanning tree card (root, parent, children, 1-hop peers with
|
||||
distances).
|
||||
|
||||
The Tree tab fetches `sovereign://fips/tree` and renders a more
|
||||
detailed tree view (coords paths, declaration sequence).
|
||||
|
||||
#### 5. Auto-refresh
|
||||
|
||||
The Network tab polls `sovereign://fips/network` every 5s (same as the
|
||||
status poll).
|
||||
|
||||
## Limitations (what we cannot show)
|
||||
|
||||
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
|
||||
Bloom filters propagate node *existence*, not *edges*. To build a
|
||||
full graph, every node would need to expose its peer list (a future
|
||||
FIPS protocol extension), or the browser would need to query each
|
||||
node's control socket individually (not practical across the mesh).
|
||||
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
|
||||
and `age_ms`, so we can show staleness, but there's no event stream
|
||||
for join/leave (would need a FIPS control subscription protocol).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── fips_control.c # Add show_tree(), show_identity_cache()
|
||||
├── fips_control.h # Add declarations
|
||||
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
|
||||
www/
|
||||
├── fips.html # Add Network + Tree tabs
|
||||
├── fips.css # Tab + network-table styles
|
||||
├── fips.js # Network + tree rendering, tab switching
|
||||
```
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: Network summary + known nodes
|
||||
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
|
||||
2. Add `handle_fips_network_json()` combining status + peers + identity cache
|
||||
3. Add `sovereign://fips/network` route
|
||||
4. Add "Network" tab to the page with mesh summary + known-nodes table
|
||||
5. Mark direct peers with a badge
|
||||
|
||||
### Phase 2: Spanning tree view
|
||||
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
|
||||
2. Add `sovereign://fips/tree` route
|
||||
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
|
||||
4. Optional: simple ASCII/indented tree rendering
|
||||
|
||||
### Phase 3: Polish
|
||||
1. Sort known nodes by last-seen (most recent first)
|
||||
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
|
||||
3. Click a node to see its detail (ipv6, age, whether direct peer)
|
||||
4. Copy-to-clipboard for npub / ipv6
|
||||
5. Optional: bloom-filter visualization (advanced/debug tab)
|
||||
303
plans/global-sidebar-per-window.md
Normal file
303
plans/global-sidebar-per-window.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Global Agent Sidebar — Per-Window, Shared Chat Session
|
||||
|
||||
## Problem
|
||||
|
||||
The agent chat sidebar is currently **per-tab**. Each [`tab_info_t`](src/tab_manager.h:23)
|
||||
carries its own `sidebar_webview`, `sidebar_container`, `paned`, and
|
||||
`sidebar_visible` flag. [`tab_manager_toggle_sidebar()`](src/tab_manager.c:2635)
|
||||
only affects the active tab. When you switch tabs, the sidebar disappears
|
||||
because the new tab has `sidebar_visible = FALSE`.
|
||||
|
||||
Meanwhile, the chat session is already **global** —
|
||||
[`agent_chat_store.c`](src/agent_chat_store.c:36) has a single
|
||||
`g_current_session_id` shared across all tabs and windows. This creates a
|
||||
mismatch: global chat state, per-tab sidebar UI.
|
||||
|
||||
## Solution
|
||||
|
||||
Move the sidebar from per-tab to **per-window**. Each window gets a
|
||||
window-level `GtkPaned` (sidebar | notebook). The sidebar persists across
|
||||
tab switches within a window. Each window's sidebar visibility is
|
||||
independent. All sidebars show the same global chat conversation.
|
||||
|
||||
```
|
||||
Window 1 (main) Window 2 (auxiliary)
|
||||
┌──────────┬──────────────────┐ ┌──────────┬──────────────────┐
|
||||
│ Sidebar │ GtkNotebook │ │ Sidebar │ GtkNotebook │
|
||||
│ (webview)│ Tab 1 | Tab 2 │ │ (webview)│ Tab 3 │
|
||||
│ chat │ main webview │ │ chat │ main webview │
|
||||
│ [input] │ │ │ [input] │ │
|
||||
└──────────┴──────────────────┘ └──────────┴──────────────────┘
|
||||
↘ ↙
|
||||
Same global chat session (g_current_session_id)
|
||||
```
|
||||
|
||||
### Layout change
|
||||
|
||||
**Current** (sidebar inside each tab's page):
|
||||
|
||||
```
|
||||
window
|
||||
└── vbox
|
||||
└── notebook
|
||||
└── tab->page (GtkBox: toolbar + paned)
|
||||
├── toolbar (hamburger + url + bookmark)
|
||||
└── paned (GtkPaned horizontal)
|
||||
├── sidebar_container [hidden]
|
||||
└── webview
|
||||
```
|
||||
|
||||
**New** (sidebar at window level, outside notebook):
|
||||
|
||||
```
|
||||
window
|
||||
└── vbox
|
||||
└── window_paned (GtkPaned horizontal) ← NEW
|
||||
├── sidebar_container [hidden] ← moved here
|
||||
└── notebook
|
||||
└── tab->page (GtkBox: toolbar + webview)
|
||||
├── toolbar
|
||||
└── webview ← direct child now
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
### New struct: `window_state_t`
|
||||
|
||||
Sidebar state moves out of `tab_info_t` into a per-window struct:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
GtkWindow *window;
|
||||
GtkWidget *notebook;
|
||||
GtkWidget *paned; /* window-level: sidebar | notebook */
|
||||
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
|
||||
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
|
||||
gboolean sidebar_visible;
|
||||
} window_state_t;
|
||||
```
|
||||
|
||||
The tab manager maintains:
|
||||
- `static window_state_t g_main_window` — the main window
|
||||
- `static GArray *g_aux_windows` — dynamic array of auxiliary windows
|
||||
- `static window_state_t *g_active_ws` — pointer to the focused window's state
|
||||
|
||||
### What gets removed from `tab_info_t`
|
||||
|
||||
```c
|
||||
/* REMOVE these from tab_info_t: */
|
||||
WebKitWebView *sidebar_webview;
|
||||
GtkWidget *sidebar_container;
|
||||
GtkWidget *paned;
|
||||
gboolean sidebar_visible;
|
||||
```
|
||||
|
||||
Each tab's `page` goes back to a simple `GtkBox` (toolbar + webview) with no
|
||||
paned.
|
||||
|
||||
### Resolution helpers
|
||||
|
||||
```c
|
||||
/* Return the window_state_t for the currently focused window. */
|
||||
static window_state_t *get_active_window_state(void);
|
||||
|
||||
/* Return the window_state_t whose notebook matches the given widget. */
|
||||
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
|
||||
```
|
||||
|
||||
These replace the pattern of "get active tab, read tab->paned / tab->sidebar_*".
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1 — Add `window_state_t` and tracking arrays to `tab_manager.c`
|
||||
|
||||
Add the struct, `g_main_window`, `g_aux_windows` (GArray), and `g_active_ws`.
|
||||
Add helper functions `get_active_window_state()` and
|
||||
`window_state_for_notebook()`.
|
||||
|
||||
No public API change yet — this is internal scaffolding.
|
||||
|
||||
### Step 2 — Restructure `tab_manager_init()` for the main window
|
||||
|
||||
Currently ([line 2271](src/tab_manager.c:2271)):
|
||||
- Creates `g_notebook`, adds it to `parent` (the vbox)
|
||||
|
||||
New:
|
||||
- Create `g_notebook` (same as now)
|
||||
- Create `g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL)`
|
||||
- Create `g_main_window.sidebar_container = gtk_box_new(VERTICAL, 0)`
|
||||
- Pack sidebar_container as child 1 (FALSE, FALSE), hide it
|
||||
- Pack notebook as child 2 (TRUE, TRUE)
|
||||
- Set paned position to 0 (sidebar hidden)
|
||||
- Add `g_main_window.paned` to `parent` (the vbox) instead of the notebook
|
||||
- Set `g_main_window.window = window`, `g_main_window.notebook = g_notebook`
|
||||
- Set `g_active_ws = &g_main_window`
|
||||
- Keep `g_active_window` / `g_active_notebook` globals for backward compat
|
||||
(they're used by `get_effective_notebook()` and `tab_manager_get_active()`)
|
||||
|
||||
### Step 3 — Simplify `tab_create()` (remove per-tab paned)
|
||||
|
||||
Currently ([line 2005](src/tab_manager.c:2005)):
|
||||
- Creates `tab->paned`, `tab->sidebar_container`, packs webview into paned,
|
||||
packs paned into `tab->page`
|
||||
|
||||
New:
|
||||
- Pack `tab->webview` directly into `tab->page` (after the toolbar)
|
||||
- Remove all paned/sidebar_container/sidebar_webview/sidebar_visible setup
|
||||
- Remove the `gtk_widget_hide(tab->sidebar_container)` calls in
|
||||
`tab_manager_new_tab()` ([line 2403](src/tab_manager.c:2403)) and
|
||||
`tab_manager_new_window()` ([line 848](src/tab_manager.c:848))
|
||||
|
||||
### Step 4 — Restructure `tab_manager_new_window()` for auxiliary windows
|
||||
|
||||
Currently ([line 783](src/tab_manager.c:783)):
|
||||
- Creates window, creates notebook, adds notebook to window, creates tab
|
||||
|
||||
New:
|
||||
- Create window
|
||||
- Create notebook
|
||||
- Create a `window_state_t` for this auxiliary window
|
||||
- Create `ws->paned`, `ws->sidebar_container`, pack sidebar + notebook into
|
||||
paned, add paned to window (instead of adding notebook directly)
|
||||
- Set `ws->window`, `ws->notebook`, `ws->sidebar_visible = FALSE`
|
||||
- Append `ws` to `g_aux_windows`
|
||||
- Create the tab (same as now, via `tab_create()` + `tab_array_add()`)
|
||||
- Wire `focus-in-event` to update `g_active_ws` (in addition to
|
||||
`g_active_window` / `g_active_notebook`)
|
||||
- Wire `destroy` to remove from `g_aux_windows` and free the
|
||||
`window_state_t` (but NOT the sidebar webview if we want to keep it
|
||||
alive — actually, destroy the whole window_state_t since the window is
|
||||
going away)
|
||||
|
||||
### Step 5 — Update `on_window_focus_in()` to set `g_active_ws`
|
||||
|
||||
Currently ([line 760](src/tab_manager.c:760)):
|
||||
- Sets `g_active_window` and `g_active_notebook`
|
||||
|
||||
New:
|
||||
- Also set `g_active_ws = window_state_for_notebook(notebook)`
|
||||
|
||||
### Step 6 — Update `on_aux_window_destroy()` to clean up `g_aux_windows`
|
||||
|
||||
Currently ([line 773](src/tab_manager.c:773)):
|
||||
- Reverts `g_active_window` / `g_active_notebook` to main window
|
||||
|
||||
New:
|
||||
- Find the `window_state_t` in `g_aux_windows` matching the destroyed window
|
||||
- Remove it from the array and free it
|
||||
- Revert `g_active_ws` to `&g_main_window`
|
||||
|
||||
### Step 7 — Rewrite `sidebar_create_webview()` to be window-scoped
|
||||
|
||||
Currently ([line 2601](src/tab_manager.c:2601)):
|
||||
- Takes a `tab_info_t *tab`, creates webview, packs into `tab->sidebar_container`
|
||||
|
||||
New:
|
||||
- Takes a `window_state_t *ws`, creates webview, packs into
|
||||
`ws->sidebar_container`
|
||||
- Same webview settings, nostr_inject_setup, load `AGENT_CHAT_URL_STR`
|
||||
|
||||
### Step 8 — Rewrite `tab_manager_toggle_sidebar()` to be window-scoped
|
||||
|
||||
Currently ([line 2635](src/tab_manager.c:2635)):
|
||||
- Gets active tab, toggles `tab->paned` / `tab->sidebar_container` /
|
||||
`tab->sidebar_visible`
|
||||
|
||||
New:
|
||||
- `window_state_t *ws = get_active_window_state()`
|
||||
- If `ws->sidebar_visible`: set paned position to 0, hide sidebar_container,
|
||||
set `sidebar_visible = FALSE`
|
||||
- Else: lazily create sidebar webview if NULL, set paned position to
|
||||
`SIDEBAR_DEFAULT_WIDTH`, show sidebar_container, set `sidebar_visible = TRUE`
|
||||
|
||||
### Step 9 — Update `tab_manager_sidebar_visible()`
|
||||
|
||||
Currently ([line 2666](src/tab_manager.c:2666)):
|
||||
- Returns `tab->sidebar_visible` for the active tab
|
||||
|
||||
New:
|
||||
- `window_state_t *ws = get_active_window_state()`
|
||||
- Return `ws ? ws->sidebar_visible : FALSE`
|
||||
|
||||
### Step 10 — Update `tab_manager.h`
|
||||
|
||||
Remove from `tab_info_t`:
|
||||
```c
|
||||
WebKitWebView *sidebar_webview;
|
||||
GtkWidget *sidebar_container;
|
||||
GtkWidget *paned;
|
||||
gboolean sidebar_visible;
|
||||
```
|
||||
|
||||
The public API (`tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible`,
|
||||
`tab_manager_get_main_webview`) stays the same — signatures unchanged.
|
||||
|
||||
### Step 11 — Verify `tab_manager_get_main_webview()` still works
|
||||
|
||||
Currently ([line 2659](src/tab_manager.c:2659)):
|
||||
- Gets active tab, returns `tab->webview`
|
||||
|
||||
This still works — the sidebar is now outside the notebook, so
|
||||
`tab_manager_get_active()` (which resolves by notebook page widget) will
|
||||
never return the sidebar. No change needed, but verify after the refactor.
|
||||
|
||||
### Step 12 — Build and test
|
||||
|
||||
```bash
|
||||
make clean && make
|
||||
./browser.sh start --login-method generate
|
||||
```
|
||||
|
||||
Test checklist:
|
||||
- [ ] Ctrl+Shift+A toggles sidebar in main window
|
||||
- [ ] Sidebar persists when switching tabs (the core fix)
|
||||
- [ ] Open a `target="_blank"` link → new window, sidebar hidden
|
||||
- [ ] Focus new window, Ctrl+Shift+A → sidebar appears in that window
|
||||
- [ ] Both windows' sidebars show the same chat conversation
|
||||
- [ ] `;` shortcut in URL bar opens sidebar if hidden, sends message
|
||||
- [ ] Agent tools (snapshot, click, eval) target the active tab's webview,
|
||||
not the sidebar
|
||||
- [ ] Close auxiliary window → no crash, main window still works
|
||||
- [ ] Close main window → app exits cleanly
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/tab_manager.h`](src/tab_manager.h) | Remove 4 sidebar fields from `tab_info_t` |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c) | Add `window_state_t` + tracking; restructure `tab_manager_init`, `tab_create`, `tab_manager_new_window`, `on_window_focus_in`, `on_aux_window_destroy`; rewrite `sidebar_create_webview`, `tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible` |
|
||||
|
||||
## Files NOT modified
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| [`src/agent_tools.c`](src/agent_tools.c) | Already uses `tab_manager_get_main_webview()` — unchanged |
|
||||
| [`src/agent_chat.c`](src/agent_chat.c) | Already uses `tab_manager_sidebar_visible()` / `tab_manager_toggle_sidebar()` — unchanged |
|
||||
| [`src/main.c`](src/main.c) | Shortcut handler already calls `tab_manager_toggle_sidebar()` — unchanged. The vbox→paned→notebook nesting is handled inside `tab_manager_init()` |
|
||||
| [`src/shortcuts.c`](src/shortcuts.c) | Shortcut binding unchanged |
|
||||
| [`www/agents/chat.js`](www/agents/chat.js) | Chat page JS unchanged — it polls `sovereign://agents/messages` regardless of which window's sidebar it's in |
|
||||
| [`src/agent_chat_store.c`](src/agent_chat_store.c) | Global session unchanged |
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
1. **`gtk_widget_show_all()` revealing the sidebar** — The current code has
|
||||
explicit `gtk_widget_hide(tab->sidebar_container)` calls after
|
||||
`show_all` in `tab_manager_new_tab` and `tab_manager_new_window`. With
|
||||
the sidebar at window level, `show_all` on the window will reveal it.
|
||||
Mitigation: call `gtk_widget_hide(ws->sidebar_container)` after
|
||||
`gtk_widget_show_all(window)` in both `tab_manager_init` and
|
||||
`tab_manager_new_window`.
|
||||
|
||||
2. **Paned position reset on window resize** — GtkPaned may reset position
|
||||
on certain resize events. Mitigation: use `gtk_paned_set_position()` on
|
||||
show, and test resizing behavior.
|
||||
|
||||
3. **Auxiliary window sidebar webview lifecycle** — When an aux window is
|
||||
destroyed, its sidebar webview must be destroyed too (it's a child of
|
||||
the window). This happens automatically via GTK container destruction,
|
||||
but verify no dangling pointers in `g_aux_windows`.
|
||||
|
||||
4. **`g_active_ws` stale pointer** — If an aux window is destroyed while
|
||||
it's the active window, `on_aux_window_destroy` must reset
|
||||
`g_active_ws = &g_main_window`. Same pattern as the existing
|
||||
`g_active_window` / `g_active_notebook` reset.
|
||||
342
plans/keyboard-shortcuts.md
Normal file
342
plans/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Plan: Configurable Keyboard Shortcuts
|
||||
|
||||
## Overview
|
||||
|
||||
Make browser-level keyboard shortcuts user-configurable on the `sovereign://settings` page. The user presses the desired key combination directly in the page (no typing "ctrl-F1" into a text box); the page captures the physical key press, formats it, and persists it. On the C side, the existing hardcoded `on_key_press()` dispatcher in [`main.c`](src/main.c:230) is refactored to look up bindings from a new `shortcuts` module.
|
||||
|
||||
## Nostr sync (NIP-78, kind 30078)
|
||||
|
||||
Per [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md), kind `30078` is an addressable event for **arbitrary custom app data** — its first listed use case is literally *"User personal settings on Nostr clients."* We use it to sync all sovereign_browser user settings (not just shortcuts) across devices.
|
||||
|
||||
### Event design
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [
|
||||
["d", "sovereign_browser"],
|
||||
["client", "sovereign_browser"]
|
||||
],
|
||||
"content": "<NIP-44 encrypted JSON object of all user settings>",
|
||||
"created_at": <unix timestamp>,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **`d` tag**: `"sovereign_browser"` — namespacing so other NIP-78 apps don't collide.
|
||||
- **`content`**: NIP-44 encrypted (to self, same pattern as bookmarks in [`bookmarks.c`](src/bookmarks.c:162)) JSON object containing all user-configurable settings: shortcuts bindings, tab preferences, agent server config, bootstrap relays, security toggles. This keeps potentially sensitive config (relay list, origins) private.
|
||||
- **Plaintext payload** (before encryption):
|
||||
```json
|
||||
{
|
||||
"shortcuts": {
|
||||
"new_tab": "<Control>t",
|
||||
"close_tab": "<Control>w",
|
||||
...
|
||||
},
|
||||
"settings": {
|
||||
"restore_session": true,
|
||||
"new_tab_url": "https://example.com",
|
||||
"tab_bar_position": "top",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sync flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["User changes shortcut in settings UI"] --> B["shortcuts_set action accel"]
|
||||
B --> C["Update in-memory g_parsed"]
|
||||
B --> D["db_kv_set shortcut.X"]
|
||||
D --> E["Debounced publish 500ms"]
|
||||
E --> F["Serialize all settings to JSON"]
|
||||
F --> G["NIP-44 encrypt to self"]
|
||||
G --> H["Build kind 30078 event d=sovereign_browser"]
|
||||
H --> I["Sign with signer"]
|
||||
I --> J["Publish to bootstrap relays"]
|
||||
I --> K["Store in SQLite db_store_event"]
|
||||
|
||||
L["Browser startup / login"] --> M["relay_fetch_thread fetches kind 30078"]
|
||||
M --> N["Store in SQLite"]
|
||||
N --> O["shortcuts_load_from_nostr: decrypt + parse"]
|
||||
O --> P["Merge: Nostr values override local defaults, local overrides win on conflict timestamp"]
|
||||
P --> Q["Populate in-memory g_parsed + write db_kv cache"]
|
||||
```
|
||||
|
||||
### Merge / conflict policy
|
||||
|
||||
On startup, after the relay fetch thread completes (existing [`relay_fetch_thread`](src/relay_fetch.c) already fetches kinds 0/3/10002 — we add 30078), we decrypt the latest kind 30078 `d=sovereign_browser` event and merge:
|
||||
|
||||
1. **Local SQLite `db_kv` is the fast-path cache** — `shortcuts_load()` reads from it for instant startup (no waiting for relays).
|
||||
2. **When the Nostr event arrives**, compare its `created_at` to a stored `settings.nostr_synced_at` timestamp in `db_kv`.
|
||||
3. **If Nostr is newer**: overwrite local `db_kv` from the decrypted payload, reload in-memory bindings, reload the settings page if open.
|
||||
4. **If local is newer** (user changed something offline): keep local, and schedule a re-publish so Nostr catches up.
|
||||
5. **First run / no Nostr event**: defaults are used; nothing published until the user changes something.
|
||||
|
||||
This mirrors how bookmarks work but is simpler (single event vs. one-per-directory).
|
||||
|
||||
### Read-only / no-login mode
|
||||
|
||||
When there's no signer (read-only or `--no-login` mode), NIP-44 encryption isn't available. In that case:
|
||||
- Settings still work fully via local `db_kv` (the fast path).
|
||||
- No kind 30078 event is published or fetched.
|
||||
- The settings page shows a note: *"Nostr sync disabled — log in with a signing key to sync settings across devices."*
|
||||
|
||||
### What goes in the Nostr payload
|
||||
|
||||
| Setting | Synced via NIP-78? | Rationale |
|
||||
|---------|-------------------|-----------|
|
||||
| Shortcut bindings | ✅ Yes | User preference, device-independent |
|
||||
| Tab bar position, close buttons, drag reorder | ✅ Yes | UI preference |
|
||||
| `new_tab_url`, `max_tabs` | ✅ Yes | Preference |
|
||||
| `restore_session` | ❌ No | Device-specific (which tabs were open) |
|
||||
| `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins` | ❌ No | Device-specific (port may differ, origins may be localhost) |
|
||||
| `agent_login_timeout_ms` | ❌ No | Device-specific |
|
||||
| `bootstrap_relays` | ✅ Yes | User's relay list should follow them |
|
||||
| Security toggles (dev_extras, file_access, universal_access) | ❌ No | Device-specific debugging flags |
|
||||
|
||||
The `settings_sync.c` module (see below) knows which keys belong in the Nostr payload via a whitelist.
|
||||
|
||||
## WebKitGTK built-in shortcuts (research finding)
|
||||
|
||||
WebKitGTK webviews consume standard **editing** shortcuts at the webview level *before* they reach our window-level `key-press-event` handler:
|
||||
|
||||
- `Ctrl+C` / `Ctrl+V` / `Ctrl+X` / `Ctrl+A` — clipboard
|
||||
- `Ctrl+Z` / `Ctrl+Y` — undo/redo
|
||||
- `Ctrl+F` — in-page find (only if a `WebKitFindController` is wired up; currently not)
|
||||
- `Tab` / `Shift+Tab` — focus traversal within the page
|
||||
- `F5` / `Ctrl+R` — reload (WebKit handles these for the webview)
|
||||
|
||||
**Browser-level** shortcuts (tab management, URL focus, tab cycling) are **not** handled by WebKit — they are ours, currently hardcoded in [`on_key_press()`](src/main.c:230). These are the ones we make configurable.
|
||||
|
||||
**Conflict policy**: If a user assigns a binding that WebKit consumes for the focused webview (e.g. `Ctrl+C`), the webview eats it and our handler never sees it. The settings UI will warn when a chosen combo is in a "reserved by web content" list, but will still allow it (the user may want it to apply only when the URL bar / tab strip has focus). Recommended defaults stick to `Ctrl+` letter combos and `F1`–`F12` which are safe.
|
||||
|
||||
## Binding format
|
||||
|
||||
Use **GTK accelerator string format** (parseable by `gtk_accelerator_parse`):
|
||||
|
||||
- `<Control>t` — Ctrl+T
|
||||
- `<Control><Shift>Tab` — Ctrl+Shift+Tab
|
||||
- `<Control>F5` — Ctrl+F5
|
||||
- `F1` — bare function key
|
||||
|
||||
This gives us free parsing/serialization via `gtk_accelerator_parse()` / `gtk_accelerator_name()` and keeps the stored representation compact and human-readable.
|
||||
|
||||
## Action registry
|
||||
|
||||
A fixed enum of browser actions the user can bind. Each has: id, display label, description, default binding.
|
||||
|
||||
| Action ID | Label | Default binding |
|
||||
|----------------------|------------------------|---------------------------|
|
||||
| `new_tab` | New tab | `<Control>t` |
|
||||
| `close_tab` | Close tab | `<Control>w` |
|
||||
| `focus_url` | Focus URL bar | `<Control>l` |
|
||||
| `next_tab` | Next tab | `<Control>Tab` |
|
||||
| `prev_tab` | Previous tab | `<Control><Shift>Tab` |
|
||||
| `next_tab_pagedown` | Next tab (PageDown) | `<Control>Page_Down` |
|
||||
| `prev_tab_pageup` | Previous tab (PageUp) | `<Control>Page_Up` |
|
||||
| `reload` | Reload page | `F5` |
|
||||
| `force_reload` | Force reload | `<Shift>F5` |
|
||||
| `go_back` | Go back | `<Alt>Left` |
|
||||
| `go_forward` | Go forward | `<Alt>Right` |
|
||||
| `find` | Find in page | `<Control>f` |
|
||||
| `open_settings` | Open settings page | `<Control>comma` |
|
||||
| `new_identity` | Switch identity | `<Control><Shift>i` |
|
||||
| `toggle_fullscreen` | Toggle fullscreen | `F11` |
|
||||
|
||||
(Exact list is adjustable; the registry is a single table in `shortcuts.c` so adding/removing actions is trivial.)
|
||||
|
||||
## Storage
|
||||
|
||||
Each binding is one row in the existing `key_value` table:
|
||||
|
||||
- key: `shortcut.<action_id>` (e.g. `shortcut.new_tab`)
|
||||
- value: accelerator string (e.g. `<Control>t`)
|
||||
|
||||
Loaded once at startup into an in-memory array indexed by action enum. `settings_save()` is not extended — the shortcuts module owns its own load/save via `db_kv_get`/`db_kv_set`.
|
||||
|
||||
## New module: `shortcuts.h` / `shortcuts.c`
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["db_kv_get shortcut.*"] --> B["shortcuts_load"]
|
||||
B --> C["g_bindings array"]
|
||||
D["on_key_press event"] --> E["shortcuts_lookup event"]
|
||||
C --> E
|
||||
E --> F["action enum or -1"]
|
||||
F --> G["dispatch switch in main.c"]
|
||||
H["sovereign://settings/set?key=shortcut.X"] --> I["shortcuts_set action accel"]
|
||||
I --> J["db_kv_set + update g_bindings"]
|
||||
```
|
||||
|
||||
### `shortcuts.h` (public API)
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
SHORTCUT_PREV_TAB,
|
||||
SHORTCUT_NEXT_TAB_PAGEDOWN,
|
||||
SHORTCUT_PREV_TAB_PAGEUP,
|
||||
SHORTCUT_RELOAD,
|
||||
SHORTCUT_FORCE_RELOAD,
|
||||
SHORTCUT_GO_BACK,
|
||||
SHORTCUT_GO_FORWARD,
|
||||
SHORTCUT_FIND,
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
/* Load all bindings from db_kv_get into the in-memory array.
|
||||
* Call once at startup after db_init(). Missing keys use defaults. */
|
||||
void shortcuts_load(void);
|
||||
|
||||
/* Look up which action a key event matches, or -1 if none.
|
||||
* Parses the stored accelerator strings once (cached) and compares
|
||||
* keyval + mods. */
|
||||
int shortcuts_lookup(GdkEventKey *event);
|
||||
|
||||
/* Get the accelerator string for an action (e.g. for the settings UI). */
|
||||
const char *shortcuts_get(shortcut_action_t action);
|
||||
|
||||
/* Set and persist a new binding for an action.
|
||||
* accel_str must be parseable by gtk_accelerator_parse.
|
||||
* Returns 0 on success, -1 on parse failure. */
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str);
|
||||
|
||||
/* Reset an action to its default binding (and persist). */
|
||||
void shortcuts_reset(shortcut_action_t action);
|
||||
|
||||
/* Reset all actions to defaults (and persist). */
|
||||
void shortcuts_reset_all(void);
|
||||
|
||||
/* Metadata for the settings UI: label, description, default accel. */
|
||||
typedef struct {
|
||||
const char *id; /* "new_tab" */
|
||||
const char *label; /* "New tab" */
|
||||
const char *desc; /* "Open a new tab" */
|
||||
const char *dflt; /* "<Control>t" */
|
||||
} shortcut_meta_t;
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
|
||||
```
|
||||
|
||||
### `shortcuts.c` (implementation notes)
|
||||
|
||||
- Static array `static struct { guint keyval; GdkModifierType mods; } g_parsed[SHORTCUT_COUNT];` populated by `gtk_accelerator_parse()` on load and on each `shortcuts_set()`.
|
||||
- `shortcuts_lookup()` applies `gtk_accelerator_get_default_mod_mask()` to the event state, then linear-scans `g_parsed` comparing `keyval` and masked `mods`. Returns first match (actions are distinct enough that collisions are the user's problem; the UI warns on duplicates).
|
||||
- `shortcuts_set()` calls `gtk_accelerator_parse(accel_str, &key, &mods)`; returns -1 if parse fails. On success, updates `g_parsed`, writes `db_kv_set("shortcut.<id>", accel_str)`.
|
||||
|
||||
## Refactor `on_key_press()` in `main.c`
|
||||
|
||||
Replace the hardcoded if/else chain with:
|
||||
|
||||
```c
|
||||
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) {
|
||||
int action = shortcuts_lookup(event);
|
||||
if (action < 0) return FALSE;
|
||||
|
||||
switch ((shortcut_action_t)action) {
|
||||
case SHORTCUT_NEW_TAB: tab_manager_new_tab(NULL); return TRUE;
|
||||
case SHORTCUT_CLOSE_TAB: tab_manager_close_active(); return TRUE;
|
||||
case SHORTCUT_FOCUS_URL: /* ... existing Ctrl+L logic ... */ return TRUE;
|
||||
case SHORTCUT_NEXT_TAB: tab_manager_next(); return TRUE;
|
||||
case SHORTCUT_PREV_TAB: tab_manager_prev(); return TRUE;
|
||||
/* ... etc ... */
|
||||
case SHORTCUT_FIND: /* open find bar (new) */ return TRUE;
|
||||
case SHORTCUT_OPEN_SETTINGS: on_menu_settings(NULL, NULL); return TRUE;
|
||||
/* ... */
|
||||
default: return FALSE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `ctrl_tab_switch` boolean setting is removed from the settings struct (or kept as a quick on/off for the whole shortcuts feature). Decision: **keep `ctrl_tab_switch` as a master "enable keyboard shortcuts" toggle** — when off, `shortcuts_lookup()` returns -1 immediately. This preserves the existing one-click disable behavior.
|
||||
|
||||
## Settings page UI — "Keyboard Shortcuts" section
|
||||
|
||||
Added to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML, between "Tabs" and "Agent Server". For each action in the registry, render a row:
|
||||
|
||||
```
|
||||
[ New tab ] [ <Control>t ] [ Change ] [ Reset ]
|
||||
[ Close tab ] [ <Control>w ] [ Change ] [ Reset ]
|
||||
...
|
||||
[ Reset all to defaults ]
|
||||
```
|
||||
|
||||
### Key-capture interaction (the "press the keys" UX)
|
||||
|
||||
Each row's **Change** button focuses a hidden capture `<input>` and shows "Press keys…". The input has a `keydown` listener that:
|
||||
|
||||
1. Calls `event.preventDefault()` and `event.stopPropagation()` so the key combo is never delivered to the page.
|
||||
2. Reads `event.ctrlKey`, `event.altKey`, `event.shiftKey`, `event.metaKey` and `event.key` / `event.code`.
|
||||
3. Maps the JS key to a GTK accelerator string. Mapping rules:
|
||||
- Modifier prefix: `<Control>` / `<Alt>` / `<Shift>` / `<Super>` in that order.
|
||||
- Key normalization: letters → lowercase; `F1`–`F12` as-is; special keys (`Tab`, `Page_Down`, `Page_Up`, `Left`, `Right`, `comma`, `space`) mapped to GDK key names.
|
||||
- Bare modifier-only presses are ignored (wait for a real key).
|
||||
4. Displays the formatted string in the row (e.g. `Ctrl+Shift+T` for display, but sends `<Control><Shift>t` to the backend).
|
||||
5. On `Enter` or blur, commits via `fetch('sovereign://settings/set?key=shortcut.new_tab&value=' + encodeURIComponent(accel))`.
|
||||
6. `Escape` cancels capture without committing.
|
||||
|
||||
A small JS lookup table maps JS `event.code`/`event.key` → GDK key name for the ~20 keys we care about. This keeps the capture logic in the page without needing a round-trip to C for each keypress.
|
||||
|
||||
### Backend handling in `handle_settings_set()`
|
||||
|
||||
Add a branch in the key/value path of [`handle_settings_set()`](src/nostr_bridge.c:705):
|
||||
|
||||
```c
|
||||
if (strncmp(key, "shortcut.", 9) == 0) {
|
||||
const char *action_id = key + 9;
|
||||
/* find action by id string in the registry */
|
||||
shortcut_action_t a = shortcuts_action_from_id(action_id);
|
||||
if (a < 0) { respond_error_json(...); return; }
|
||||
if (shortcuts_set(a, value) != 0) {
|
||||
respond_error_json(request, -1, "Invalid key combination");
|
||||
return;
|
||||
}
|
||||
/* respond with ok + reload */
|
||||
}
|
||||
```
|
||||
|
||||
Also add a `shortcut.reset` and `shortcut.reset_all` pseudo-keys (or a separate `sovereign://settings/shortcut-reset?action=X` endpoint) for the Reset buttons.
|
||||
|
||||
### Conflict / duplicate warnings
|
||||
|
||||
The settings page JS, after a successful commit, can fetch all current bindings (embedded in the page HTML on render) and highlight any other action that now shares the same accelerator string. The backend `shortcuts_set()` does **not** reject duplicates — last-wins at lookup time — but the UI flags them in red so the user notices.
|
||||
|
||||
## Files to create / modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/shortcuts.h` | **New** — action enum + public API |
|
||||
| `src/shortcuts.c` | **New** — registry, load/save (from `db_kv`), lookup, parse |
|
||||
| `src/settings_sync.h` | **New** — NIP-78 sync API: `settings_sync_init()`, `settings_sync_publish()`, `settings_sync_merge_from_nostr()` |
|
||||
| `src/settings_sync.c` | **New** — serialize whitelisted settings + shortcuts to JSON, NIP-44 encrypt to self, build/sign kind 30078 `d=sovereign_browser`, publish to relays, store in SQLite; decrypt + merge on fetch. Reuses the pattern from [`bookmarks.c`](src/bookmarks.c:214) `publish_directory()`. |
|
||||
| `src/main.c` | Refactor [`on_key_press()`](src/main.c:230) to dispatch via `shortcuts_lookup()`; call `shortcuts_load()` after `settings_load()`; call `settings_sync_init()` after login; add action handlers (find, settings, fullscreen, back/forward) |
|
||||
| `src/nostr_bridge.c` | Add "Keyboard Shortcuts" section to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML + key-capture JS; add `shortcut.*` branch to [`handle_settings_set()`](src/nostr_bridge.c:705); trigger debounced `settings_sync_publish()` on any settings change |
|
||||
| `src/settings.h` / `settings.c` | Repurpose `ctrl_tab_switch` → `shortcuts_enabled` master toggle (or keep name + add alias); update defaults + load/save |
|
||||
| `src/relay_fetch.c` / `relay_fetch.h` | Add kind 30078 to the fetched kinds; after fetch, call `settings_sync_merge_from_nostr()` to decrypt and merge |
|
||||
| `Makefile` | Add `src/shortcuts.c` and `src/settings_sync.c` to `SRC` |
|
||||
| `src/tab_manager.h` / `tab_manager.c` | Add `tab_manager_find()` / find-bar helpers if `SHORTCUT_FIND` is implemented in this phase (can be stubbed for later) |
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1a (local):** shortcuts module + refactor `on_key_press` + settings UI with key capture. Covers all currently-hardcoded actions plus `reload`, `go_back`, `go_forward`, `open_settings`, `toggle_fullscreen` (these already have tab_manager/menu functions to call). Persistence via `db_kv` only.
|
||||
|
||||
**Phase 1b (Nostr sync):** `settings_sync` module — publish kind 30078 on debounced settings changes, fetch + merge on startup. This makes shortcuts (and other whitelisted settings) sync across devices.
|
||||
|
||||
**Phase 2 (future):** `find` (requires wiring `WebKitFindController` + a find bar widget), `new_identity` (calls `app_menu_switch_identity_proxy`), `force_reload` (bypass cache).
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual: open `sovereign://settings`, press Change on "New tab", press `Ctrl+Q`, verify it persists and `Ctrl+Q` now opens a new tab on next key press (same session — `shortcuts_set` updates the in-memory array immediately).
|
||||
- Restart browser, verify binding persisted in SQLite.
|
||||
- Reset single + reset all buttons restore defaults.
|
||||
- Duplicate-binding warning appears when two actions share a combo.
|
||||
- Bare modifier press (just Ctrl) doesn't commit; Escape cancels.
|
||||
- `shortcuts_enabled = false` disables all lookup.
|
||||
78
plans/mcp-improvements.md
Normal file
78
plans/mcp-improvements.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# MCP Server Improvements Plan
|
||||
|
||||
## Problem Assessment
|
||||
|
||||
The sovereign_browser MCP server uses JavaScript-level interaction (JS `element.click()`,
|
||||
`dispatchEvent`, `document.querySelector`) which has critical limitations:
|
||||
|
||||
1. **CSS selectors with special chars** (e.g. `[60vh]`) break in JSON escaping
|
||||
2. **JS `.click()` doesn't trigger SPA framework handlers** (React synthetic events, Radix UI pointer events)
|
||||
3. **Stale refs fail completely** instead of re-resolving
|
||||
4. **No coordinate-based interaction** — everything depends on CSS selectors working
|
||||
|
||||
## agent-browser's Approach (CDP)
|
||||
|
||||
agent-browser uses Chrome DevTools Protocol:
|
||||
- `Accessibility.getFullAXTree` → native AX tree with `backend_node_id` per node
|
||||
- `DOM.getBoxModel(backend_node_id)` → pixel coordinates of element
|
||||
- `Input.dispatchMouseEvent(x, y)` → real browser-level mouse events
|
||||
- Stale `backend_node_id` → re-query AX tree by role+name
|
||||
|
||||
## Proposed Improvements for sovereign_browser
|
||||
|
||||
### 1. Coordinate-based clicking (highest impact, easiest)
|
||||
|
||||
Instead of JS `.click()`, get the element's bounding box and synthesize a GDK event:
|
||||
|
||||
```c
|
||||
// In agent_tools.c, the click tool:
|
||||
// 1. JS: getBoundingClientRect() → {x, y, width, height}
|
||||
// 2. C: gdk_event_new(GDK_BUTTON_PRESS) at center coordinates
|
||||
// 3. C: gtk_main_do_event() to dispatch to WebKit
|
||||
```
|
||||
|
||||
This triggers real WebKit hit-testing and full event propagation, matching how
|
||||
agent-browser's `Input.dispatchMouseEvent` works.
|
||||
|
||||
### 2. Fix ref storage — store bounding box, not just CSS selector
|
||||
|
||||
At snapshot time, store each ref's:
|
||||
- `backend_node_id` (WebKit accessible's unique ID)
|
||||
- `role` + `name` (for re-resolution if stale)
|
||||
- `bounding_box` (x, y, width, height — for coordinate-based fallback)
|
||||
- `selector` (CSS selector — as secondary fallback)
|
||||
|
||||
### 3. Stale ref fallback — re-resolve by role+name
|
||||
|
||||
If the CSS selector fails, re-query the accessibility tree by role+name
|
||||
(matching agent-browser's `find_node_id_by_role_name` approach).
|
||||
|
||||
### 4. Use WebKit's native accessibility tree for snapshots
|
||||
|
||||
Instead of walking the DOM in JS, use WebKit's ATK accessibility:
|
||||
```c
|
||||
WebKitWebView *wv = ...;
|
||||
AtkObject *root = webkit_web_view_get_accessible(wv);
|
||||
// Walk the ATK tree — stable node IDs, no CSS selector issues
|
||||
```
|
||||
|
||||
### 5. Add `click_at(x, y)` tool
|
||||
|
||||
A simple coordinate-based click tool that synthesizes GDK events.
|
||||
Useful when you know the coordinates from a screenshot.
|
||||
|
||||
### 6. Fix `fill`/`type` to use GDK keyboard events
|
||||
|
||||
Instead of JS `element.value = ...`, use:
|
||||
- JS to focus the element
|
||||
- GDK key events to type character by character
|
||||
- This triggers proper input validation and React onChange handlers
|
||||
|
||||
## Priority
|
||||
|
||||
1. **Coordinate-based clicking** — fixes the immediate "can't click buttons" problem
|
||||
2. **Fix ref storage with bounding box** — enables coordinate fallback
|
||||
3. **Stale ref fallback** — improves reliability
|
||||
4. **Native AX tree snapshots** — better stability, less JS dependency
|
||||
5. **GDK keyboard events for typing** — better SPA compatibility
|
||||
6. **`click_at` tool** — convenience for screenshot-based interaction
|
||||
672
plans/network-services-subprocesses.md
Normal file
672
plans/network-services-subprocesses.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# Network Services (Tor + FIPS) — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
sovereign_browser integrates two network services:
|
||||
|
||||
1. **Tor** — SOCKS proxy for Tor-routed transport. Naturally
|
||||
browser-managed: unprivileged subprocess, no system-level access.
|
||||
The browser points WebKitGTK's proxy at Tor's SOCKS port.
|
||||
|
||||
2. **FIPS** — Mesh networking daemon for Nostr-identity-based
|
||||
peer-to-peer connectivity. System networking infrastructure: TUN
|
||||
interface, DNS resolver integration, capabilities, persistent
|
||||
identity. Best treated as a system service the browser attaches to,
|
||||
with browser-managed mode added only after privilege and resolver
|
||||
handling are proven.
|
||||
|
||||
Both services can be either **browser-managed** (spawned as child
|
||||
processes) or **attached** (connected to an already-running system
|
||||
instance). The browser detects which mode is available and never stops
|
||||
or reconfigures a service it doesn't own.
|
||||
|
||||
```
|
||||
sovereign_browser (C99/GTK main loop)
|
||||
│
|
||||
├── Tor: managed subprocess OR attached to system Tor
|
||||
│ └── WebKitGTK SOCKS proxy → tor → Tor network → internet
|
||||
│ (fail-closed: no direct fallback while Tor mode is on)
|
||||
│
|
||||
├── FIPS: attached to system fips.service OR managed subprocess
|
||||
│ └── http://<npub>.fips/ → TUN → FIPS mesh → remote node
|
||||
│ (fips:// is a redirect shorthand, not a custom scheme handler)
|
||||
│
|
||||
└── Settings UI: enable/disable, view status, manage lifecycle
|
||||
```
|
||||
|
||||
### Important: Tor-routed ≠ Tor Browser
|
||||
|
||||
WebKit routed through Tor is **not Tor Browser**. It lacks Tor
|
||||
Browser's anti-fingerprinting, site isolation policy, first-party
|
||||
isolation, WebRTC hardening, and uniform browser fingerprint. This
|
||||
feature provides **Tor-routed transport**, not guaranteed anonymity.
|
||||
The settings UI should make this distinction clear.
|
||||
|
||||
---
|
||||
|
||||
## Service Ownership Model
|
||||
|
||||
Each service has an explicit ownership state:
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `DISABLED` | User has not enabled this service |
|
||||
| `DISCOVERING` | Checking for an existing system instance |
|
||||
| `ATTACHING` | Connecting to an already-running service's control socket |
|
||||
| `STARTING` | Spawning a managed subprocess |
|
||||
| `BOOTSTRAPPING` | Subprocess is running but not yet ready (Tor < 100%, FIPS initializing) |
|
||||
| `READY` | Service is operational |
|
||||
| `STOPPING` | Graceful shutdown in progress |
|
||||
| `EXITED` | Service stopped (managed) or disconnected (attached) |
|
||||
| `FAILED` | Service crashed or failed to start |
|
||||
|
||||
**Critical rules:**
|
||||
|
||||
- **Attached services are read-only.** The browser may query status and
|
||||
observe, but must never stop, restart, or reconfigure a service it
|
||||
didn't start.
|
||||
- **Managed services are owned.** The browser starts, stops, monitors,
|
||||
and may reconfigure them.
|
||||
- **Fail-closed for Tor.** If Tor mode is enabled and Tor is not READY,
|
||||
network requests are blocked or show a waiting page. Never silently
|
||||
fall back to direct access.
|
||||
- **Graceful degradation for FIPS.** If FIPS is not running, `fips://`
|
||||
and `.fips` URLs show an error. Normal HTTP/HTTPS traffic is
|
||||
unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Net Services Module (`net_services.c` / `net_services.h`)
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
NET_SERVICE_TOR,
|
||||
NET_SERVICE_FIPS,
|
||||
} net_service_type_t;
|
||||
|
||||
typedef enum {
|
||||
SERVICE_DISABLED,
|
||||
SERVICE_DISCOVERING,
|
||||
SERVICE_ATTACHING,
|
||||
SERVICE_STARTING,
|
||||
SERVICE_BOOTSTRAPPING,
|
||||
SERVICE_READY,
|
||||
SERVICE_STOPPING,
|
||||
SERVICE_EXITED,
|
||||
SERVICE_FAILED,
|
||||
} service_state_t;
|
||||
|
||||
typedef enum {
|
||||
OWNERSHIP_NONE, /* not yet determined */
|
||||
OWNERSHIP_ATTACHED, /* connected to existing system service */
|
||||
OWNERSHIP_MANAGED, /* spawned and owned by the browser */
|
||||
} service_ownership_t;
|
||||
|
||||
typedef struct {
|
||||
net_service_type_t type;
|
||||
service_state_t state;
|
||||
service_ownership_t ownership;
|
||||
GPid pid; /* valid only when OWNERSHIP_MANAGED */
|
||||
char *binary_path;
|
||||
char *data_dir;
|
||||
int control_fd; /* socket to control interface */
|
||||
char *error_msg;
|
||||
guint child_watch; /* g_child_watch_add source */
|
||||
guint poll_timer; /* periodic status poll */
|
||||
/* Service-specific status (updated via control protocol) */
|
||||
union {
|
||||
struct {
|
||||
int bootstrap_progress; /* 0-100 */
|
||||
char circuit_info[256];
|
||||
} tor;
|
||||
struct {
|
||||
int peer_count;
|
||||
char node_npub[80]; /* FIPS node's own npub */
|
||||
char tree_state[32]; /* root/leaf/branch */
|
||||
gboolean tun_active;
|
||||
char tun_name[16];
|
||||
} fips;
|
||||
} status;
|
||||
} net_service_t;
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```c
|
||||
/* Initialize the net services module (called on startup) */
|
||||
void net_services_init(void);
|
||||
|
||||
/* Enable and start/attach a service */
|
||||
int net_service_enable(net_service_type_t type);
|
||||
|
||||
/* Disable and stop/detach a service */
|
||||
int net_service_disable(net_service_type_t type);
|
||||
|
||||
/* Restart a managed service (no-op for attached) */
|
||||
int net_service_restart(net_service_type_t type);
|
||||
|
||||
/* Get current state and status */
|
||||
const net_service_t *net_service_get_status(net_service_type_t type);
|
||||
|
||||
/* Check if a service is enabled in settings */
|
||||
gboolean net_service_is_enabled(net_service_type_t type);
|
||||
|
||||
/* Check if a service is READY */
|
||||
gboolean net_service_is_ready(net_service_type_t type);
|
||||
|
||||
/* Called on browser exit — stop managed services, detach from attached */
|
||||
void net_services_shutdown(void);
|
||||
```
|
||||
|
||||
### Subprocess Supervision
|
||||
|
||||
For managed children:
|
||||
|
||||
- Spawn with `g_spawn_async()` + `G_SPAWN_DO_NOT_REAP_CHILD` + child-watch
|
||||
- Capture stdout/stderr via pipes → log to browser log file
|
||||
- `g_child_watch_add()` for exit notification
|
||||
- Graceful stop: send SIGTERM, wait with timeout (5s), then SIGKILL
|
||||
- Prevent duplicate starts (check state before spawning)
|
||||
- Use per-instance data directories and control endpoints to avoid
|
||||
collisions with system instances
|
||||
- On crash: transition to `FAILED`, notify UI, do not auto-restart
|
||||
(user must explicitly re-enable)
|
||||
|
||||
---
|
||||
|
||||
## Tor Integration
|
||||
|
||||
### Discovery: Attach vs Manage
|
||||
|
||||
On enable, the browser checks:
|
||||
|
||||
1. **User-configured attach endpoint** — if `tor.attach_socks` is set in
|
||||
settings, connect to that SOCKS port and verify it's a Tor proxy.
|
||||
2. **System Tor control socket** — check `/run/tor/control` (Unix) or
|
||||
`127.0.0.1:9051` (TCP). If responsive, attach.
|
||||
3. **System Tor SOCKS port** — check if `127.0.0.1:9050` is a SOCKS
|
||||
proxy. If so, attach (without control port, status is limited).
|
||||
4. **Managed mode** — if no existing Tor found and `tor.binary_path` is
|
||||
valid, spawn a private Tor instance.
|
||||
|
||||
### Managed Tor Configuration
|
||||
|
||||
The browser generates a private torrc under the browser profile:
|
||||
|
||||
```
|
||||
DataDirectory ~/.sovereign_browser/tor/data
|
||||
SocksPort unix:/home/user/.sovereign_browser/tor/socks.sock
|
||||
ControlPort unix:/home/user/.sovereign_browser/tor/control.sock
|
||||
CookieAuthentication 1
|
||||
CookieAuthFileGroupReadable 1
|
||||
Log notice file ~/.sovereign_browser/tor/tor.log
|
||||
AvoidDiskWrites 1
|
||||
```
|
||||
|
||||
**Key choices:**
|
||||
|
||||
- **Unix sockets** for SOCKS and control (not TCP ports) — avoids port
|
||||
conflicts and is more secure (filesystem permissions).
|
||||
- **Private data directory** — no collision with system Tor.
|
||||
- **Cookie authentication** — standard, no passwords in config files.
|
||||
- Data directory mode 0700.
|
||||
|
||||
If Unix socket support is unavailable (unlikely on Linux), fall back to
|
||||
private TCP ports allocated dynamically (bind to port 0, read assigned
|
||||
port).
|
||||
|
||||
### Tor Control Protocol
|
||||
|
||||
Connect to the control socket. Authenticate with cookie (read from
|
||||
`~/.sovereign_browser/tor/control.authcookie`).
|
||||
|
||||
Key commands:
|
||||
|
||||
```
|
||||
AUTHENTICATE <hex-cookie>
|
||||
GETINFO status/bootstrap-phase
|
||||
→ 250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=85 TAG=handshake
|
||||
GETINFO signal/newnym
|
||||
SIGNAL NEWNYM
|
||||
→ 250 OK
|
||||
```
|
||||
|
||||
Poll bootstrap progress every 2 seconds. When `PROGRESS=100`, transition
|
||||
to `READY`.
|
||||
|
||||
### Fail-Closed Proxy Configuration
|
||||
|
||||
**Before Tor is READY:**
|
||||
|
||||
1. Set WebKit proxy to a non-functional placeholder (e.g.,
|
||||
`socks5://127.0.0.1:1`) to block all external traffic.
|
||||
2. Show a "Connecting to Tor… (85%)" waiting page for any navigation
|
||||
attempt.
|
||||
|
||||
**When Tor is READY:**
|
||||
|
||||
```c
|
||||
WebKitNetworkProxySettings *proxy = webkit_network_proxy_settings_new(
|
||||
"socks5://unix:/home/user/.sovereign_browser/tor/socks.sock",
|
||||
ignore_hosts);
|
||||
/* ignore_hosts must include .fips domains when FIPS is active */
|
||||
webkit_website_data_manager_set_network_proxy_settings(dm,
|
||||
WEBKIT_NETWORK_PROXY_MODE_CUSTOM, proxy);
|
||||
```
|
||||
|
||||
**When Tor exits or is disabled:**
|
||||
|
||||
- If user explicitly disabled Tor: revert to `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.
|
||||
- If Tor crashed while enabled: keep proxy blocked, show error, do NOT
|
||||
fall back to direct.
|
||||
|
||||
### DNS
|
||||
|
||||
Tor SOCKS5 supports hostname resolution — WebKitGTK sends the hostname
|
||||
to the SOCKS proxy, Tor resolves it via the Tor network. No DNS leak
|
||||
for proxied traffic.
|
||||
|
||||
**`.fips` bypass:** When FIPS is active, `.fips` hostnames must bypass
|
||||
the Tor proxy. `webkit_network_proxy_settings_new()` accepts an
|
||||
`ignore_hosts` array. Add `*.fips` to this list. **This must be tested
|
||||
empirically** — WebKitGTK's ignore-host matching for custom TLDs needs
|
||||
verification.
|
||||
|
||||
### Tor Settings
|
||||
|
||||
```ini
|
||||
[tor]
|
||||
enabled=false
|
||||
mode=auto ; auto|attach|manage
|
||||
binary_path=tor ; for managed mode
|
||||
attach_socks= ; for attach mode (e.g. socks5://127.0.0.1:9050)
|
||||
attach_control= ; for attach mode (e.g. unix:/run/tor/control)
|
||||
data_dir=~/.sovereign_browser/tor
|
||||
```
|
||||
|
||||
### Tor Status UI
|
||||
|
||||
- Toggle: Enable Tor
|
||||
- Mode: Auto-detected (Attached to system Tor / Managed)
|
||||
- Status: Disabled / Connecting (85%) / Ready / Failed (error message)
|
||||
- "New Identity" button (sends `SIGNAL NEWNYM`)
|
||||
- Warning: "Tor-routed transport. This is not Tor Browser and does not
|
||||
provide the same fingerprinting protections."
|
||||
- Advanced: custom entry/exit nodes (future)
|
||||
|
||||
---
|
||||
|
||||
## FIPS Integration
|
||||
|
||||
### Discovery: Attach vs Manage
|
||||
|
||||
On enable, the browser checks:
|
||||
|
||||
1. **User-configured control socket** — if `fips.control_socket` is set
|
||||
in settings, try connecting.
|
||||
2. **Default FIPS control socket** — check the standard resolution
|
||||
order:
|
||||
- `/run/fips/control.sock`
|
||||
- `$XDG_RUNTIME_DIR/fips/control.sock`
|
||||
- `/tmp/fips-control.sock`
|
||||
|
||||
See FIPS's [`default_control_path()`](../fips/src/config/mod.rs:142).
|
||||
3. **Managed mode** — if no existing FIPS found and `fips.binary_path`
|
||||
is valid AND the binary has `CAP_NET_ADMIN` (or user has root), spawn
|
||||
a managed instance.
|
||||
|
||||
### Attached FIPS (Production Mode)
|
||||
|
||||
Connect to the control socket. Query status:
|
||||
|
||||
```json
|
||||
{"command":"show_status"}
|
||||
```
|
||||
|
||||
Response (per [`control-socket.md`](../fips/docs/reference/control-socket.md:103)):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"version": "0.4.0",
|
||||
"npub": "npub1...",
|
||||
"node_addr": "...",
|
||||
"ipv6_addr": "fd97:...",
|
||||
"state": "running",
|
||||
"peer_count": 3,
|
||||
"tun_state": "active",
|
||||
"tun_name": "fips0",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The browser renders this in the settings UI. It does NOT stop, restart,
|
||||
or reconfigure the daemon.
|
||||
|
||||
### Managed FIPS (Development/Portable Mode)
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- FIPS binary found at `fips.binary_path`
|
||||
- Binary has `CAP_NET_ADMIN` (check with `getcap`) OR user is root
|
||||
- `.fips` DNS resolution is available (either system resolver integration
|
||||
or browser-level DNS override — see below)
|
||||
|
||||
**Config generation:**
|
||||
|
||||
The browser generates a minimal `fips.yaml` matching the actual FIPS
|
||||
config schema (see [`configuration.md`](../fips/docs/reference/configuration.md:44)):
|
||||
|
||||
```yaml
|
||||
node:
|
||||
identity:
|
||||
persistent: true # FIPS manages its own key file
|
||||
control:
|
||||
enabled: true
|
||||
socket_path: ~/.sovereign_browser/fips/control.sock
|
||||
|
||||
tun:
|
||||
device: fips0
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "::1"
|
||||
port: 5354
|
||||
|
||||
transports:
|
||||
- type: udp
|
||||
bind_addr: "0.0.0.0:4242"
|
||||
- type: tcp
|
||||
listen: "0.0.0.0:4243"
|
||||
|
||||
peers: [] # populated from settings or auto-discovery
|
||||
```
|
||||
|
||||
**Identity: FIPS owns a separate persistent identity.**
|
||||
|
||||
The browser does NOT write its own nsec into the FIPS config. FIPS
|
||||
generates and persists its own keypair (via `persistent: true`), as
|
||||
documented in [`IdentityConfig`](../fips/src/config/mod.rs:404).
|
||||
|
||||
Rationale:
|
||||
|
||||
- Read-only browser login has no private key to share.
|
||||
- NIP-46 and n_signer identities keep the private key outside the
|
||||
browser — there is no nsec to write.
|
||||
- Writing a local nsec to YAML creates a new persistent secret-at-rest
|
||||
path that bypasses the signer model.
|
||||
- FIPS's identity is a node identity, not a user identity — it doesn't
|
||||
need to match the browser's Nostr npub.
|
||||
|
||||
The settings UI shows both npubs:
|
||||
- "Browser identity: npub1abc… (local key / read-only / n_signer)"
|
||||
- "FIPS node identity: npub1def… (managed by FIPS)"
|
||||
|
||||
**Optional future: explicit identity import.** A user could choose to
|
||||
import a local nsec into FIPS config, behind an explicit warning:
|
||||
"Writing your private key to FIPS config creates a copy on disk. This
|
||||
is not recommended for NIP-46 or hardware signer users."
|
||||
|
||||
**Shutdown:**
|
||||
|
||||
FIPS does not have a control-socket shutdown command. The only mutating
|
||||
commands are `connect` and `disconnect` (see
|
||||
[`commands.rs`](../fips/src/control/commands.rs:14)). Stop the daemon
|
||||
with SIGTERM, which it handles gracefully via
|
||||
[`foreground_shutdown_signal()`](../fips/src/bin/fips.rs:185).
|
||||
|
||||
### `.fips` DNS Resolution
|
||||
|
||||
FIPS's DNS responder listens on `[::1]:5354` by default. For `.fips`
|
||||
names to resolve system-wide, one of these must be true:
|
||||
|
||||
1. **FIPS package installed** — `fips-dns.service` configures
|
||||
systemd-resolved to forward `.fips` queries to the daemon. This is
|
||||
the production path.
|
||||
2. **Manual resolver configuration** — the user has configured
|
||||
dnsmasq/systemd-resolved to forward `.fips` to `[::1]:5354`.
|
||||
3. **Browser-level DNS override** — the browser intercepts `.fips`
|
||||
lookups and resolves them by querying FIPS's DNS port directly. This
|
||||
is more complex and not yet implemented.
|
||||
|
||||
For the initial implementation, require that FIPS is installed as a
|
||||
system package (which sets up resolver integration). Browser-managed
|
||||
FIPS without resolver integration is a future enhancement.
|
||||
|
||||
### `fips://` URL Handling
|
||||
|
||||
`fips://` is NOT a custom URI scheme handler. It's a redirect shorthand.
|
||||
|
||||
When the user enters `fips://<npub>/path` or clicks such a link:
|
||||
|
||||
1. Parse the npub from the URL.
|
||||
2. Redirect to `http://<npub>.fips/path` (preserving port, path, query,
|
||||
fragment).
|
||||
3. WebKit's normal HTTP stack handles the request.
|
||||
4. The `.fips` DNS resolver translates the hostname to an `fd00::/8`
|
||||
IPv6 address.
|
||||
5. The TUN interface routes the traffic through the FIPS mesh.
|
||||
|
||||
This reuses FIPS's existing IPv6 adapter and DNS resolver entirely — no
|
||||
custom scheme handler needed.
|
||||
|
||||
When FIPS is not running, `.fips` DNS resolution fails and the user
|
||||
sees a standard "server not found" error.
|
||||
|
||||
### FIPS Settings
|
||||
|
||||
```ini
|
||||
[fips]
|
||||
enabled=false
|
||||
mode=auto ; auto|attach|manage
|
||||
binary_path=fips ; for managed mode
|
||||
control_socket= ; for attach mode (auto-detected if empty)
|
||||
config_dir=~/.sovereign_browser/fips
|
||||
```
|
||||
|
||||
### FIPS Status UI
|
||||
|
||||
- Toggle: Enable FIPS
|
||||
- Mode: Auto-detected (Attached to system fips / Managed)
|
||||
- Status: Disabled / Connecting / Ready / Failed
|
||||
- FIPS node identity: npub1def…
|
||||
- Peers: 3 connected
|
||||
- Tree state: Root / Leaf / Branch
|
||||
- TUN: fips0 (active)
|
||||
- Peer list (expandable): npub, transport, link metrics
|
||||
- "Connect to peer" input: enter npub + address + transport
|
||||
(sends `{"command":"connect","params":{...}}`)
|
||||
|
||||
---
|
||||
|
||||
## Traffic Routing
|
||||
|
||||
When Tor is enabled (fail-closed):
|
||||
|
||||
| URL | Route |
|
||||
|-----|-------|
|
||||
| `http://` / `https://` | Tor SOCKS proxy → Tor network → internet |
|
||||
| `http://<npub>.fips/` | Direct (bypasses Tor via ignore_hosts) → TUN → FIPS mesh |
|
||||
| `nostr://` / `nostr:` | Tor SOCKS proxy → Nostr relays |
|
||||
| `sovereign://` | Direct (internal, no proxy) |
|
||||
| `file://` | Direct (local, no proxy) |
|
||||
| `about:` | Direct (internal, no proxy) |
|
||||
|
||||
When Tor is disabled:
|
||||
|
||||
| URL | Route |
|
||||
|-----|-------|
|
||||
| `http://` / `https://` | Direct (no proxy) |
|
||||
| `http://<npub>.fips/` | Direct → TUN → FIPS mesh (if FIPS active) |
|
||||
| All others | Same as above |
|
||||
|
||||
**Proxy scope:** WebKitGTK's proxy settings apply to WebKit's network
|
||||
stack (HTTP/HTTPS via libsoup). They do NOT automatically proxy:
|
||||
|
||||
- C-side relay fetches (`relay_fetch.c`)
|
||||
- Agent LLM API calls
|
||||
- NIP-05 verification HTTP requests
|
||||
- FIPS's own Nostr relay connections
|
||||
|
||||
These C-side network calls would need separate proxy configuration
|
||||
(e.g., setting `SOCKS_PROXY` env var, or configuring libsoup's proxy
|
||||
directly). This is a known limitation and should be documented. A
|
||||
future phase could route all browser-originated traffic through Tor.
|
||||
|
||||
**FIPS + Tor interaction:** FIPS has its own Tor transport
|
||||
([`TorConfig`](../fips/src/config/transport.rs:523)) for FIPS's
|
||||
outbound relay connections. If the browser manages a Tor instance,
|
||||
FIPS could be configured to use it as its SOCKS proxy. This requires
|
||||
coordination: the browser's Tor SOCKS socket path must be passed to
|
||||
FIPS's config. This is a future enhancement, not Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Settings UI
|
||||
|
||||
A "Network" section in the Settings page (`sovereign://settings`):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Network Services │
|
||||
│ │
|
||||
│ ┌─ Tor ───────────────────────────────────────────────────┐ │
|
||||
│ │ [✓] Enable Tor-routed transport │ │
|
||||
│ │ Mode: Attached to system Tor │ │
|
||||
│ │ Status: Ready │ │
|
||||
│ │ ⚠ Tor-routed transport is not Tor Browser. It does not │ │
|
||||
│ │ provide the same fingerprinting protections. │ │
|
||||
│ │ [New Identity] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ FIPS Mesh ─────────────────────────────────────────────┐ │
|
||||
│ │ [✓] Enable FIPS │ │
|
||||
│ │ Mode: Attached to system fips.service │ │
|
||||
│ │ Status: Ready │ │
|
||||
│ │ FIPS node: npub1def…xyz │ │
|
||||
│ │ Peers: 3 connected │ │
|
||||
│ │ TUN: fips0 (active) │ │
|
||||
│ │ [Connect to peer…] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── net_services.c # Service ownership, state machine, supervision
|
||||
├── net_services.h # Public API
|
||||
├── tor_control.c # Tor control protocol client
|
||||
├── tor_control.h # Tor control API
|
||||
├── fips_control.c # FIPS JSON control protocol client
|
||||
├── fips_control.h # FIPS control API
|
||||
├── settings.c # Extended: tor/fips settings
|
||||
├── settings.h # Extended: tor/fips config fields
|
||||
├── main.c # Extended: init services, shutdown
|
||||
├── tab_manager.c # Extended: fips:// redirect, fail-closed page
|
||||
└── nostr_bridge.c # Extended: settings page network section
|
||||
www/
|
||||
├── settings.html # Extended: network services UI
|
||||
├── settings.js # Extended: network services logic
|
||||
└── settings.css # Extended: network services styling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Create `net_services.c` — service ownership state machine, subprocess
|
||||
supervision (spawn, child-watch, graceful stop, stdout/stderr capture)
|
||||
2. Define the `net_service_t` struct and state transitions
|
||||
3. Add Tor/FIPS settings fields to `settings.c` / `settings.h`
|
||||
4. Create stub `tor_control.c` and `fips_control.c` (connect, send, receive)
|
||||
5. Wire `net_services_init()` and `net_services_shutdown()` into `main.c`
|
||||
|
||||
### Phase 2: Attach-Only Tor Prototype
|
||||
1. Implement Tor control protocol client (authenticate, bootstrap status)
|
||||
2. Connect to an existing system Tor (attach mode only)
|
||||
3. Configure WebKitGTK SOCKS proxy with fail-closed behavior
|
||||
4. Test `.fips` bypass via ignore_hosts
|
||||
5. Verify no DNS leaks (check with https://check.torproject.org)
|
||||
6. Add Tor status UI to settings page
|
||||
|
||||
### Phase 3: Managed Tor
|
||||
1. Generate private torrc (Unix sockets, cookie auth, private data dir)
|
||||
2. Spawn tor subprocess, capture stdout/stderr
|
||||
3. Monitor bootstrap progress via control socket
|
||||
4. Implement graceful stop (SIGTERM, timeout, SIGKILL)
|
||||
5. Handle crash detection and FAILED state
|
||||
6. "New Identity" button (SIGNAL NEWNYM)
|
||||
7. Test: full lifecycle, port conflict avoidance, crash recovery
|
||||
|
||||
### Phase 4: Attach-Only FIPS
|
||||
1. Implement FIPS control protocol client (JSON line protocol)
|
||||
2. Auto-detect FIPS control socket (standard resolution order)
|
||||
3. Query `show_status`, `show_peers`, `show_tree`
|
||||
4. Render FIPS status in settings UI
|
||||
5. Verify `http://<npub>.fips/` works through TUN
|
||||
6. Do NOT manage identity, config, or lifecycle
|
||||
|
||||
### Phase 5: Managed FIPS
|
||||
1. Check prerequisites (binary found, CAP_NET_ADMIN, resolver integration)
|
||||
2. Generate minimal `fips.yaml` (FIPS owns its identity, separate key file)
|
||||
3. Spawn fips subprocess with private config
|
||||
4. Monitor via control socket
|
||||
5. Graceful stop via SIGTERM
|
||||
6. Show both browser npub and FIPS npub in settings UI
|
||||
7. Test: full lifecycle, privilege detection, resolver verification
|
||||
|
||||
### Phase 6: URL Integration
|
||||
1. Support `fips://<npub>/path` as redirect to `http://<npub>.fips/path`
|
||||
2. Preserve port, path, query, fragment
|
||||
3. Handle `nostr:` link interception (NIP-21) — already planned in
|
||||
[`nostr-uri-scheme.md`](nostr-uri-scheme.md)
|
||||
4. Show appropriate error when FIPS is not running
|
||||
|
||||
### Phase 7: Packaging
|
||||
1. Debian package: `Depends: tor` (recommended), `Recommends: fips`
|
||||
2. Version compatibility checks (FIPS control protocol is pre-1.0)
|
||||
3. Document that FIPS package sets up TUN permissions and resolver
|
||||
4. No silent binary downloading at runtime
|
||||
5. Upgrade and uninstall behavior
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should C-side network calls (relay fetch, LLM API, NIP-05) also
|
||||
route through Tor?** Currently they use libsoup directly, not
|
||||
WebKit's proxy. This needs separate proxy configuration. Phase 1
|
||||
scope: WebKit traffic only. Future: route all browser traffic.
|
||||
|
||||
2. **Should FIPS use the browser-managed Tor instance for its relay
|
||||
connections?** FIPS has its own Tor transport config. Coordinating
|
||||
the browser's Tor SOCKS socket with FIPS's config is a future
|
||||
enhancement.
|
||||
|
||||
3. **What happens when Tor mode is toggled at runtime?** Existing
|
||||
WebKit connections may need to be closed or re-established. The
|
||||
proxy change applies to new requests; in-flight requests may
|
||||
complete on the old path. This needs testing.
|
||||
|
||||
4. **Should the browser support multiple Tor instances?** No — one Tor
|
||||
instance per browser. If the user needs multiple, they should use
|
||||
system Tor with multiple SOCKS ports.
|
||||
|
||||
5. **How to verify `.fips` DNS bypass works with WebKitGTK's
|
||||
ignore_hosts?** Must be tested empirically. If `*.fips` globbing
|
||||
doesn't work, may need to enumerate known `.fips` hostnames or use
|
||||
a different bypass mechanism.
|
||||
|
||||
6. **Should managed FIPS auto-generate peers or wait for user input?**
|
||||
Default: no auto-peering. User must explicitly add peers via the
|
||||
"Connect to peer" UI. Auto-discovery (open policy) is a future
|
||||
option.
|
||||
313
plans/nostr-uri-scheme.md
Normal file
313
plans/nostr-uri-scheme.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# nostr:// URI Scheme + JSON Viewer — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Two complementary features that work together:
|
||||
|
||||
1. **JSON Viewer** (Phase 1) — A rich JSON viewer with syntax highlighting
|
||||
and collapsible tree view, injected into all `application/json`
|
||||
responses. Useful everywhere, not just for nostr://.
|
||||
|
||||
2. **nostr:// URL Handling** (Phase 2) — Smart URL bar parsing for Nostr
|
||||
bech32 entities. The browser fetches the event(s) from relays and
|
||||
displays the raw JSON (using the Phase 1 viewer). At the top of the
|
||||
page, a toolbar shows clickable links to configured external Nostr
|
||||
clients (helper applications). The browser is NOT a Nostr client —
|
||||
it shows raw data and lets the user open it in a client of their
|
||||
choice.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: JSON Viewer
|
||||
|
||||
### Goal
|
||||
|
||||
When the browser loads any `application/json` content (from any URL
|
||||
scheme — http, https, file, sovereign, nostr), instead of WebKit's
|
||||
default plain `<pre>` display, inject a rich JSON viewer with:
|
||||
|
||||
- **Syntax highlighting** — colored keys, strings, numbers, booleans, null
|
||||
- **Collapsible tree view** — click to expand/collapse objects and arrays
|
||||
- **Copy buttons** — copy the raw JSON or a specific path
|
||||
- **Dark mode** — respects `prefers-color-scheme`
|
||||
- **Search/filter** — filter keys in large JSON documents
|
||||
|
||||
### Architecture
|
||||
|
||||
WebKitGTK's built-in JSON display wraps raw JSON in a `<pre>` tag. We
|
||||
intercept this by injecting a user script that:
|
||||
|
||||
1. Detects when the page content type is `application/json`
|
||||
2. Parses the `<pre>` content as JSON
|
||||
3. Replaces the `<pre>` with a styled tree view
|
||||
|
||||
**Implementation approach:** Use
|
||||
`webkit_web_view_add_user_content_filter` or a `WebKitUserScript` injected
|
||||
via `WebKitUserContentManager`. The script runs on all pages but only
|
||||
activates when `document.contentType === 'application/json'`.
|
||||
|
||||
Alternatively, intercept at the `resource-load-started` or
|
||||
`load-changed` level and inject the viewer script after load.
|
||||
|
||||
### Files
|
||||
|
||||
```
|
||||
www/
|
||||
├── json-viewer/
|
||||
│ ├── json-viewer.js # Tree view renderer + syntax highlighting
|
||||
│ └── json-viewer.css # Tree view styles (light + dark)
|
||||
src/
|
||||
├── nostr_bridge.c # Extended: serve json-viewer.js/css via sovereign://
|
||||
├── tab_manager.c # Extended: inject json-viewer script on JSON pages
|
||||
```
|
||||
|
||||
### JSON Viewer JS Design
|
||||
|
||||
```javascript
|
||||
// Pseudo-code for the viewer
|
||||
(function() {
|
||||
if (document.contentType !== 'application/json') return;
|
||||
|
||||
var raw = document.body.textContent;
|
||||
var data;
|
||||
try { data = JSON.parse(raw); } catch(e) { return; } // not valid JSON, leave as-is
|
||||
|
||||
var viewer = createTreeView(data);
|
||||
document.body.innerHTML = '';
|
||||
document.body.appendChild(viewer);
|
||||
|
||||
function createTreeView(obj, path) {
|
||||
// Recursively create collapsible nodes:
|
||||
// - Objects: { key: value, ... } with expand/collapse
|
||||
// - Arrays: [ item, ... ] with expand/collapse
|
||||
// - Strings: green, with quotes
|
||||
// - Numbers: blue
|
||||
// - Booleans: orange
|
||||
// - Null: gray
|
||||
// - Nested objects/arrays: collapsible with item count
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Light mode: white background, dark text, colored values
|
||||
- Dark mode: dark background, light text, adjusted colors
|
||||
- Monospace font for all JSON content
|
||||
- Indentation guides (vertical lines for nesting depth)
|
||||
- Hover highlight on rows
|
||||
- Expand/collapse arrows (▶ ▼)
|
||||
|
||||
### Settings
|
||||
|
||||
- `json_viewer_enabled` (default: true) — toggle in Settings dialog
|
||||
- Can be disabled per-page via a keyboard shortcut (e.g., Ctrl+Shift+J)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: nostr:// URL Handling
|
||||
|
||||
### Goal
|
||||
|
||||
The browser recognizes Nostr bech32 entities in the URL bar, fetches the
|
||||
event(s) from relays, and displays the raw JSON using the Phase 1 JSON
|
||||
viewer. At the top of the page, a toolbar shows clickable links to
|
||||
configured external Nostr clients (helper applications). The browser is
|
||||
NOT a Nostr client — it shows raw data and lets the user choose to open
|
||||
it in an external client.
|
||||
|
||||
### User Flow
|
||||
|
||||
1. User pastes `npub1abc...` or `nostr:npub1abc...` in the URL bar
|
||||
2. Browser fetches the relevant event(s) from relays
|
||||
3. Browser displays the raw JSON with the nice tree viewer (Phase 1)
|
||||
4. At the top of the page, a toolbar shows: "Open in: [Jumble] [Snort] [njump.me]"
|
||||
5. Clicking a helper app link opens that client's URL for the same entity in a new tab
|
||||
|
||||
### URL Bar Smart Parsing
|
||||
|
||||
When the user types or pastes something in the URL bar, the browser
|
||||
should recognize these formats and normalize them:
|
||||
|
||||
| Input | Recognized as | Normalized to |
|
||||
|-------|---------------|---------------|
|
||||
| `npub1abc...` | NIP-19 npub | `nostr:npub1abc...` |
|
||||
| `nsec1abc...` | NIP-19 nsec | (rejected — private key, don't navigate) |
|
||||
| `note1abc...` | NIP-19 note | `nostr:note1abc...` |
|
||||
| `nevent1abc...` | NIP-19 nevent | `nostr:nevent1abc...` |
|
||||
| `naddr1abc...` | NIP-19 naddr | `nostr:naddr1abc...` |
|
||||
| `nprofile1abc...` | NIP-19 nprofile | `nostr:nprofile1abc...` |
|
||||
| `nrelay1abc...` | NIP-19 nrelay | `nostr:nrelay1abc...` |
|
||||
| `nostr:npub1abc...` | NIP-21 URI | (as-is) |
|
||||
| `nostr://npub1abc...` | Our scheme | (as-is) |
|
||||
|
||||
**NIP-21** defines `nostr:` as the standard prefix for bech32 entities
|
||||
in links. We support both `nostr:npub1...` (NIP-21 standard) and
|
||||
`nostr://npub1...` (our scheme variant).
|
||||
|
||||
### nostr:// URI Scheme Handler
|
||||
|
||||
Register `nostr://` (and handle `nostr:`) as a WebKit URI scheme. When
|
||||
navigated to:
|
||||
|
||||
1. Parse the bech32 entity from the URL
|
||||
2. Determine what events to fetch based on entity type:
|
||||
- `npub`/`nprofile`: kind 0 (metadata), recent kind 1 (notes), kind 3 (contacts), kind 10002 (relays)
|
||||
- `note`/`nevent`: the specific event by ID
|
||||
- `naddr`: the event by kind + pubkey + d-tag
|
||||
- `nrelay`: NIP-11 relay info (HTTP GET)
|
||||
3. Fetch events from relays (using relay hints from the entity, or user's configured relays)
|
||||
4. Return the event(s) as JSON with `Content-Type: application/json`
|
||||
5. The Phase 1 JSON viewer renders it with the tree view
|
||||
6. The JSON viewer also injects a helper-app toolbar at the top of the page
|
||||
|
||||
### Helper App Toolbar
|
||||
|
||||
When the JSON viewer detects that the page is a `nostr://` response, it
|
||||
adds a toolbar above the JSON tree:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Nostr entity: npub1abc... │ Open in: [Jumble] [Snort] [njump] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each button is a link that opens the helper app's URL for this entity
|
||||
in a new tab. The toolbar is styled to match the JSON viewer's theme.
|
||||
|
||||
### Helper App Configuration
|
||||
|
||||
In the Settings dialog, users configure a list of helper applications:
|
||||
|
||||
```
|
||||
Helper applications for Nostr entities:
|
||||
Name: Jumble URL: https://jumble.social/{entity}
|
||||
Name: Snort URL: https://snort.social/{entity}
|
||||
Name: njump.me URL: https://njump.me/{entity}
|
||||
Name: habla.news URL: https://habla.news/a/{naddr}
|
||||
[+ Add helper app]
|
||||
```
|
||||
|
||||
The `{entity}` placeholder is replaced with the full bech32 string
|
||||
(e.g., `npub1abc...`). The `{naddr}` placeholder is replaced with the
|
||||
naddr string (for article-specific clients). The `{id}` placeholder is
|
||||
replaced with the hex event ID.
|
||||
|
||||
**Default helper apps** (if user hasn't configured):
|
||||
- njump.me — `https://njump.me/{entity}` (universal, handles all entity types)
|
||||
- Snort — `https://snort.social/{entity}`
|
||||
- Jumble — `https://jumble.social/{entity}`
|
||||
|
||||
### Settings Storage
|
||||
|
||||
```ini
|
||||
[nostr]
|
||||
helper_apps=Jumble|https://jumble.social/{entity},Snort|https://snort.social/{entity},njump.me|https://njump.me/{entity}
|
||||
```
|
||||
|
||||
Stored as a comma-separated list of `Name|URL` pairs. Parsed on startup
|
||||
and passed to the JSON viewer via the `sovereign://` bridge.
|
||||
|
||||
### Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── nostr_url.c # URL parsing + entity detection (new)
|
||||
├── nostr_url.h # Public API (new)
|
||||
├── nostr_scheme.c # nostr:// URI scheme handler (new)
|
||||
├── nostr_scheme.h # Scheme handler API (new)
|
||||
├── tab_manager.c # Extended: URL bar smart parsing
|
||||
├── settings.c # Extended: helper app config
|
||||
├── nostr_bridge.c # Extended: settings page + helper app API
|
||||
└── relay_fetch.c # Extended: ad-hoc event fetch by entity
|
||||
www/
|
||||
├── json-viewer/
|
||||
│ └── json-viewer.js # Extended: helper app toolbar for nostr:// pages
|
||||
├── settings.html # Extended: helper app config UI
|
||||
└── settings.js # Extended: save/load helper app config
|
||||
```
|
||||
|
||||
### URL Bar Integration
|
||||
|
||||
In `tab_manager.c`, the `normalize_url()` function (or the URL entry
|
||||
activate handler) is extended:
|
||||
|
||||
```c
|
||||
/* Before trying to parse as a URL, check if it's a Nostr bech32 entity */
|
||||
if (nostr_url_detect(input) != NOSTR_ENTITY_NONE) {
|
||||
char *nostr_url = nostr_url_normalize(input);
|
||||
/* Navigate to the nostr:// scheme — the scheme handler will
|
||||
* fetch events and return JSON */
|
||||
webkit_web_view_load_uri(webview, nostr_url);
|
||||
g_free(nostr_url);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Link Interception
|
||||
|
||||
When a page contains `nostr:npub1...` links (NIP-21 format), the
|
||||
`on_decide_policy` handler intercepts the navigation:
|
||||
|
||||
```c
|
||||
if (strncmp(uri, "nostr:", 6) == 0) {
|
||||
/* Normalize and navigate via our nostr:// scheme handler */
|
||||
char *nostr_url = nostr_url_normalize(uri);
|
||||
webkit_web_view_load_uri(webview, nostr_url);
|
||||
g_free(nostr_url);
|
||||
return TRUE; /* ignore the original navigation */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NIP-19 / NIP-21 Reference
|
||||
|
||||
### NIP-19: bech32-encoded entities
|
||||
|
||||
| Prefix | Description | Data |
|
||||
|--------|-------------|------|
|
||||
| `npub1` | Public key | 32-byte pubkey |
|
||||
| `nsec1` | Private key | 32-byte privkey |
|
||||
| `note1` | Event ID | 32-byte event ID |
|
||||
| `nprofile1` | Profile | TLV: pubkey + relay hints |
|
||||
| `nevent1` | Event | TLV: event ID + relay hints + author |
|
||||
| `naddr1` | Address | TLV: kind + pubkey + d-tag + relay hints |
|
||||
| `nrelay1` | Relay | TLV: relay URL |
|
||||
|
||||
### NIP-21: URI scheme
|
||||
|
||||
NIP-21 defines `nostr:` as the standard URI prefix:
|
||||
- `nostr:npub1abc...` — link to a profile
|
||||
- `nostr:note1abc...` — link to a note
|
||||
- `nostr:nevent1abc...` — link to an event
|
||||
- `nostr:naddr1abc...` — link to an addressable event
|
||||
- `nostr:nprofile1abc...` — link to a profile (with relay hints)
|
||||
- `nostr:nrelay1abc...` — link to a relay
|
||||
|
||||
We support both `nostr:entity` (NIP-21) and `nostr://entity` (our
|
||||
variant) — they're treated identically.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: JSON Viewer
|
||||
1. Create `www/json-viewer/json-viewer.js` — tree view + syntax highlighting
|
||||
2. Create `www/json-viewer/json-viewer.css` — styling (light + dark)
|
||||
3. Embed via `embed_web_files.sh`
|
||||
4. Inject into webviews via `WebKitUserScript` or content filter
|
||||
5. Add `json_viewer_enabled` setting
|
||||
6. Test with local .json files, API responses, and sovereign:// endpoints
|
||||
|
||||
### Phase 2: nostr:// URL Handling
|
||||
1. Create `nostr_url.c` — bech32 entity detection and parsing
|
||||
2. Extend `nip019` in nostr_core_lib to decode TLV entities (nprofile, nevent, naddr, nrelay)
|
||||
3. Create `nostr_scheme.c` — register `nostr://` URI scheme handler
|
||||
4. Extend `relay_fetch.c` — ad-hoc event fetch by entity type
|
||||
5. Add helper app configuration to settings (list of Name|URL pairs)
|
||||
6. Extend URL bar `normalize_url()` to detect bare bech32 entities
|
||||
7. Extend `on_decide_policy` to intercept `nostr:` links
|
||||
8. Extend JSON viewer to show helper app toolbar on `nostr://` pages
|
||||
9. Add settings UI for configuring helper apps
|
||||
10. Test with various bech32 entities pasted in URL bar
|
||||
121
plans/per-user-profiles.md
Normal file
121
plans/per-user-profiles.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Per-User Profile Directory System
|
||||
|
||||
## Problem
|
||||
|
||||
When logging in as a new user (different nsec), the browser:
|
||||
1. Restores the previous user's tabs (session restore from global SQLite)
|
||||
2. Shows the previous user's Nostr events on the profile page
|
||||
3. Shows the previous user's bookmarks, history, and recents
|
||||
|
||||
## Solution
|
||||
|
||||
Create a per-user profile directory keyed by npub, with each user getting their own SQLite database, session, bookmarks, and history.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
~/.sovereign_browser/
|
||||
├── identity.json # last used npub (for login dialog default)
|
||||
├── global.db # global settings (agent server, theme, shortcuts, inspector)
|
||||
└── profiles/
|
||||
├── npub1abc.../
|
||||
│ ├── browser.db # per-user: events, history, session, bookmarks, key_value
|
||||
│ └── identity.json # this user's nsec/npub
|
||||
├── npub1def.../
|
||||
│ ├── browser.db
|
||||
│ └── identity.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
## What Stays Global vs Per-User
|
||||
|
||||
### Global (in `~/.sovereign_browser/global.db`)
|
||||
- `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins`, `agent_login_timeout_ms`
|
||||
- `theme_dark`
|
||||
- `shortcut.*` (keyboard shortcuts)
|
||||
- `inspector_x/y/w/h`
|
||||
- `dev_extras`, `file_access`, `universal_access` (security settings — these are WebKit-level, not per-user)
|
||||
|
||||
### Per-User (in `~/.sovereign_browser/profiles/<npub>/browser.db`)
|
||||
- `restore_session`, `new_tab_url`, `tab_bar_position`, `show_tab_close_buttons`, `middle_click_close`, `ctrl_tab_switch`, `max_tabs`, `tab_drag_reorder`
|
||||
- `bootstrap_relays`, `search_engine`
|
||||
- Session (open tabs)
|
||||
- History
|
||||
- Bookmarks
|
||||
- Nostr events (kind 0, 3, 10002)
|
||||
- key_value table (per-user settings)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add `db_init_with_path(const char *path)` to db.c
|
||||
|
||||
Currently `db_init()` hardcodes `~/.sovereign_browser/browser.db`. Add a variant that takes a path. Keep `db_init()` for the global database.
|
||||
|
||||
### Step 2: Add profile directory management
|
||||
|
||||
New functions in a `profile.c` module (or add to `key_store.c`):
|
||||
- `profile_get_dir(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/`
|
||||
- `profile_ensure_dir(const char *npub)` — creates the directory if it doesn't exist
|
||||
- `profile_get_db_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/browser.db`
|
||||
- `profile_get_identity_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/identity.json`
|
||||
|
||||
### Step 3: Split settings into global and per-user
|
||||
|
||||
In `settings.c`:
|
||||
- `settings_load_global()` — loads from global.db (agent server, theme, shortcuts, inspector, security)
|
||||
- `settings_load_user()` — loads from the per-user browser.db (session, tabs, relays, search)
|
||||
- `settings_save_global()` — saves global settings to global.db
|
||||
- `settings_save_user()` — saves per-user settings to browser.db
|
||||
|
||||
Or simpler: keep one `settings_load()` / `settings_save()` but have it read from the appropriate database based on which keys are global vs per-user.
|
||||
|
||||
### Step 4: Change the login flow in main.c
|
||||
|
||||
Current flow:
|
||||
1. `db_init()` — opens global browser.db
|
||||
2. `settings_load()` — loads all settings from browser.db
|
||||
3. `shortcuts_load()` — loads shortcuts from browser.db
|
||||
4. Login (GTK dialog or agent login or CLI)
|
||||
5. `relay_fetch()` — fetches events from relays
|
||||
6. `session_restore()` — restores tabs from browser.db
|
||||
|
||||
New flow:
|
||||
1. `db_init_global()` — opens global.db for global settings
|
||||
2. `settings_load_global()` — loads global settings (agent server, theme, shortcuts, inspector)
|
||||
3. `shortcuts_load()` — loads shortcuts from global.db
|
||||
4. Login — get npub
|
||||
5. `profile_ensure_dir(npub)` — create `~/.sovereign_browser/profiles/<npub>/`
|
||||
6. `db_init_user(npub)` — close global.db, open `profiles/<npub>/browser.db`
|
||||
7. `settings_load_user()` — load per-user settings from browser.db
|
||||
8. `relay_fetch()` — fetch events from relays into per-user browser.db
|
||||
9. `session_restore()` — restore tabs from per-user browser.db
|
||||
|
||||
### Step 5: Migration
|
||||
|
||||
On first run with the new system:
|
||||
- If `~/.sovereign_browser/browser.db` exists and `~/.sovereign_browser/profiles/` doesn't:
|
||||
- After login, copy `browser.db` to `profiles/<npub>/browser.db`
|
||||
- Move global settings from browser.db to global.db
|
||||
- Keep browser.db as backup (or rename to browser.db.old)
|
||||
|
||||
### Step 6: Identity persistence
|
||||
|
||||
- `~/.sovereign_browser/identity.json` — stores the last-used npub (not nsec)
|
||||
- `~/.sovereign_browser/profiles/<npub>/identity.json` — stores the nsec/npub for that profile
|
||||
- On startup, read the last-used npub from global identity.json to pre-fill the login dialog
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/db.c` / `src/db.h` — add `db_init_with_path()`, `db_close()`, `db_init_global()`
|
||||
2. `src/settings.c` / `src/settings.h` — split into global/per-user load/save
|
||||
3. `src/main.c` — change the startup flow
|
||||
4. `src/key_store.c` — update identity persistence to per-profile
|
||||
5. `src/session.c` — no change (already uses db_session_* which will use the per-user db)
|
||||
6. `src/shortcuts.c` — load from global.db instead of browser.db
|
||||
7. New: `src/profile.c` / `src/profile.h` — profile directory management
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Medium risk** — changes the database initialization flow which affects all data access
|
||||
- **Migration needed** — existing users need their data moved to per-profile dirs
|
||||
- **Testing needed** — login with different users, verify data isolation
|
||||
295
plans/processes-window.md
Normal file
295
plans/processes-window.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Processes Window — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
A `sovereign://processes` internal page that lets the user pinpoint which
|
||||
open tab is consuming CPU and dig into *what that page is doing*. Primary
|
||||
use case: diagnosing `~/lt/client`, which currently burns CPU across many
|
||||
tabs.
|
||||
|
||||
## The core technical reality
|
||||
|
||||
The codebase deliberately **shares one WebProcess across tabs** — every
|
||||
new tab/webview is created with `webkit_web_view_new_with_related_view()`
|
||||
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
|
||||
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
|
||||
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
|
||||
|
||||
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
|
||||
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
|
||||
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
|
||||
|
||||
The plan therefore uses **two layers**:
|
||||
|
||||
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
|
||||
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
|
||||
the single-tab-per-process case and shows overall footprint.
|
||||
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
|
||||
is what actually pinpoints the offending tab *within* a shared
|
||||
WebProcess and tells you what it is doing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BrowserProcess[sovereign_browser main PID]
|
||||
ProcInfo[process_info.c reads /proc]
|
||||
Bridge[nostr_bridge.c sovereign:// routes]
|
||||
AgentServer[agent_server / MCP]
|
||||
end
|
||||
|
||||
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
|
||||
ProbeA[perf-probe.js in tab A]
|
||||
ProbeB[perf-probe.js in tab B]
|
||||
ProbeC[perf-probe.js in tab C]
|
||||
end
|
||||
|
||||
ProbeA -->|sovereign://processes/probe-report POST| Bridge
|
||||
ProbeB -->|sovereign://processes/probe-report POST| Bridge
|
||||
ProbeC -->|sovereign://processes/probe-report POST| Bridge
|
||||
|
||||
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
|
||||
Bridge -->|JSON| UI[www/processes.js renders tables]
|
||||
Bridge -->|JSON| AgentServer
|
||||
```
|
||||
|
||||
## Layer 1 — Process view (`src/process_info.{c,h}`)
|
||||
|
||||
### Process discovery
|
||||
|
||||
| Process | How found |
|
||||
|---|---|
|
||||
| Main (`sovereign_browser`) | `getpid()` |
|
||||
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
|
||||
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
|
||||
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
|
||||
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
|
||||
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
|
||||
|
||||
### Per-process fields (all from `/proc`, no external deps)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| pid | — |
|
||||
| name | `/proc/<pid>/comm` |
|
||||
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
|
||||
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
|
||||
| ppid | `/proc/<pid>/stat` field 4 |
|
||||
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
|
||||
| rss_kb | `/proc/<pid>/status` → `VmRSS` |
|
||||
| pss_kb | `/proc/<pid>/smaps_rollup` → `Pss` (fallback to RSS if unavailable) |
|
||||
| uss_kb | `/proc/<pid>/smaps_rollup` → `Private_Clean` + `Private_Dirty` |
|
||||
| vmpeak_kb | `/proc/<pid>/status` → `VmPeak` |
|
||||
| vmswap_kb | `/proc/<pid>/status` → `VmSwap` |
|
||||
| threads | count of entries in `/proc/<pid>/task/` |
|
||||
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
|
||||
| io_read_kb | `/proc/<pid>/io` → `read_bytes` / 1024 |
|
||||
| io_write_kb | `/proc/<pid>/io` → `write_bytes` / 1024 |
|
||||
| fd_count | count of entries in `/proc/<pid>/fd/` |
|
||||
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
|
||||
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
|
||||
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
|
||||
|
||||
### CPU% computation
|
||||
|
||||
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
|
||||
1. Read current `utime+stime` and `time(NULL)`.
|
||||
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
|
||||
3. Store current as prev.
|
||||
|
||||
Caller polls `sovereign://processes/list` every 1s; first call returns
|
||||
0% (no baseline), second call onward is accurate. Same approach as
|
||||
`top`/`htop`.
|
||||
|
||||
### API
|
||||
|
||||
```c
|
||||
/* src/process_info.h */
|
||||
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
|
||||
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
|
||||
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
|
||||
```
|
||||
|
||||
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
|
||||
|
||||
Injected into every webview via `WebKitUserContentManager` (same pattern
|
||||
as the existing `window.nostr` injection in
|
||||
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
|
||||
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
|
||||
`WebKitUserScriptInjectAtDocumentStart`).
|
||||
|
||||
### Signals collected
|
||||
|
||||
| Signal | Source | What it tells you |
|
||||
|---|---|---|
|
||||
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
|
||||
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
|
||||
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
|
||||
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
|
||||
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
|
||||
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
|
||||
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
|
||||
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
|
||||
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
|
||||
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
|
||||
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
|
||||
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
|
||||
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
|
||||
|
||||
### Reporting
|
||||
|
||||
The probe batches a report every 1s and POSTs it to
|
||||
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
|
||||
route handler in `nostr_bridge.c` calls
|
||||
`process_info_record_probe(N, body)`, which stores the latest probe per
|
||||
tab index in a static array (sized to `tab_manager_count()`).
|
||||
|
||||
The probe must NOT run on `sovereign://` internal pages (settings, fips,
|
||||
processes itself, agents) — skip injection when the URL scheme is
|
||||
`sovereign://`. This avoids self-noise and recursion.
|
||||
|
||||
### Probe payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"tab_index": 3,
|
||||
"cpu_busy_percent": 42.5,
|
||||
"fps": 12,
|
||||
"timer_count": 38,
|
||||
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
|
||||
"net_in_flight": 3,
|
||||
"net_requests_last_sec": 8,
|
||||
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
|
||||
"heap_used_mb": 124.3,
|
||||
"event_listener_count": 217,
|
||||
"worker_count": 2,
|
||||
"dom_node_count": 4821,
|
||||
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
|
||||
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
|
||||
}
|
||||
```
|
||||
|
||||
## Routes (added to `nostr_bridge.c`)
|
||||
|
||||
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
|
||||
|
||||
| Route | Handler | Returns |
|
||||
|---|---|---|
|
||||
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
|
||||
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
|
||||
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
|
||||
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
|
||||
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
|
||||
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
|
||||
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
|
||||
|
||||
## UI (`www/processes.{html,css,js}`)
|
||||
|
||||
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
|
||||
|
||||
### Tab 1 — Processes
|
||||
|
||||
Sortable table, default sort by `cpu_percent` desc:
|
||||
|
||||
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
|
||||
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
|
||||
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
|
||||
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
|
||||
|
||||
Click a WebKitWebProcess row → expands inline to list its hosted tabs
|
||||
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
|
||||
hot tab even within a shared process.
|
||||
|
||||
### Tab 2 — Tabs (the primary diagnostic view)
|
||||
|
||||
Sortable table, default sort by `cpu_busy_percent` desc:
|
||||
|
||||
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
|
||||
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
|
||||
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
|
||||
|
||||
Click a tab row → **drill-down panel** opens below with:
|
||||
- Long-task timeline (last 30s, sparkline)
|
||||
- Top long-task sources (script URLs + total ms)
|
||||
- Active timers list (code label, interval, count)
|
||||
- Recent network requests (URL, type, count)
|
||||
- Worker count, event listener count, heap trend
|
||||
- Action buttons: **Reload tab**, **Suspend tab** (replace with
|
||||
`about:blank` keeping URL), **Close tab**
|
||||
|
||||
### Styling
|
||||
|
||||
Reuse `sovereign-base.css` and the card/table styles from
|
||||
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
|
||||
(green→yellow→red by CPU%) for instant visual scan.
|
||||
|
||||
## Menu integration
|
||||
|
||||
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
|
||||
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
|
||||
(or opens a new tab if none active) — same pattern as
|
||||
`open_fips_cb` at line 332.
|
||||
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
|
||||
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
|
||||
`open_processes`.
|
||||
|
||||
## MCP tools (so an agent can self-diagnose)
|
||||
|
||||
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
|
||||
[`agent_mcp.c`](../src/agent_mcp.c:1):
|
||||
|
||||
| Tool | Args | Returns |
|
||||
|---|---|---|
|
||||
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
|
||||
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
|
||||
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
|
||||
|
||||
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
|
||||
and tell me what it's doing" — the agent calls `processes.tabs`, finds
|
||||
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
|
||||
drill-down, and reports the offending script/timer/endpoint.
|
||||
|
||||
## Build / packaging
|
||||
|
||||
- Add `src/process_info.c` to `Makefile` sources.
|
||||
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
|
||||
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
|
||||
into the binary via `serve_embedded_file()`.
|
||||
- Register the perf-probe user script in the shared
|
||||
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
|
||||
`main.c` near where `nostr_inject` is wired), with a URL filter that
|
||||
excludes `sovereign://*`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make`
|
||||
2. `./browser.sh restart --login-method generate`
|
||||
3. Open `sovereign://processes` — verify main + WebKit processes listed
|
||||
with sane CPU%/RSS.
|
||||
4. Open several tabs of a CPU-heavy page (e.g.
|
||||
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
|
||||
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
|
||||
view ranks them by `cpu_busy_percent` and the drill-down shows
|
||||
long-task sources.
|
||||
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
|
||||
identifiable and the drill-down shows which script/timer/endpoint is
|
||||
responsible.
|
||||
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
|
||||
JSON shape matches the contract above.
|
||||
|
||||
## Out of scope (future work)
|
||||
|
||||
- Forcing process-per-tab via `WebKitWebsitePolicies` /
|
||||
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
|
||||
per-tab `/proc` attribution but breaks the existing
|
||||
`related_view` sharing workaround. Tracked separately.
|
||||
- Historical recording (save probe samples to SQLite for trend charts).
|
||||
- Per-tab network throttling / CPU limiting (would need WebKit API
|
||||
support that may not exist).
|
||||
- Killing/suspending a tab's WebProcess specifically (currently only
|
||||
per-tab reload/close, which is safe).
|
||||
127
plans/search-engine.md
Normal file
127
plans/search-engine.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Search Engine Integration & URL Bar Dropdown
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate a search engine (DuckDuckGo default, user-changeable) into the URL bar. When the user types, a dropdown appears with **direct links first** (from history, bookmarks, and domain heuristics), followed by **search engine suggestions**. Selecting a direct link navigates to that URL; selecting a search suggestion goes to the search engine results page.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User types in URL entry] --> B[on_url_changed callback]
|
||||
B --> C[Clear suggestion list store]
|
||||
C --> D[db_history_search query]
|
||||
C --> E[bookmarks_search query]
|
||||
C --> F[domain heuristic]
|
||||
D --> G[Add direct-link rows to model]
|
||||
E --> G
|
||||
F --> G
|
||||
B --> H[Async SoupRequest to DDG ac API]
|
||||
H --> I[Add search-suggestion rows to model]
|
||||
G --> J[GtkEntryCompletion dropdown shows]
|
||||
I --> J
|
||||
J --> K{User selects a row}
|
||||
K -->|direct link| L[webkit_web_view_load_uri url]
|
||||
K -->|search suggestion| M[load search engine URL with query]
|
||||
K -->|Enter, no selection| N{Is input a URL?}
|
||||
N -->|Yes| L
|
||||
N -->|No| M
|
||||
```
|
||||
|
||||
## Data Sources for Direct Links
|
||||
|
||||
1. **History** — SQLite `history` table, searched by URL/title substring, ranked by `visit_count DESC, visited_at DESC`. These are sites the user has actually visited.
|
||||
2. **Bookmarks** — In-memory bookmark list, searched by URL/title substring.
|
||||
3. **Domain heuristic** — If the typed text has no spaces and no dots, offer `https://<text>.org` and `https://<text>.com` as "Navigate directly" options. Handles first-time visits to known domains.
|
||||
|
||||
## Search Engine Suggestions
|
||||
|
||||
DuckDuckGo autocomplete API: `https://duckduckgo.com/ac/?q=<query>&type=list`
|
||||
Returns a JSON array: `["wikipedia", "wikipedia english", ...]`
|
||||
Fetched asynchronously via libsoup (already linked). Results populate the lower section of the dropdown. Selecting one navigates to `https://duckduckgo.com/?q=<term>`.
|
||||
|
||||
## Search Engine Configuration
|
||||
|
||||
Built-in engines hardcoded in `search.c`:
|
||||
|
||||
| Engine | Search URL | Suggestion URL |
|
||||
|--------|-----------|----------------|
|
||||
| DuckDuckGo | `https://duckduckgo.com/?q=%s` | `https://duckduckgo.com/ac/?q=%s&type=list` |
|
||||
| Google | `https://www.google.com/search?q=%s` | `https://suggestqueries.google.com/complete/search?client=firefox&q=%s` |
|
||||
| Brave | `https://search.brave.com/search?q=%s` | `https://search.brave.com/api/suggest?q=%s` |
|
||||
| Startpage | `https://www.startpage.com/sp/search?query=%s` | (none — no autocomplete) |
|
||||
| Searx | `https://searx.be/search?q=%s` | (none) |
|
||||
|
||||
The selected engine name is stored in settings (`search_engine` key) and synced via NIP-78.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `src/search.h` + `src/search.c`
|
||||
- `search_engine_t` struct: name, search_url_template, suggestion_url_template
|
||||
- `search_engines_get()` — returns array of built-in engines
|
||||
- `search_engine_get_active()` — returns the currently selected engine
|
||||
- `search_engine_build_search_url(const char *query)` — builds a search URL from the active engine's template
|
||||
- `search_engine_build_suggestion_url(const char *query)` — builds the suggestion API URL
|
||||
- `search_suggest_fetch_async(const char *query, GFunc callback, gpointer user_data)` — async libsoup HTTP GET, parses JSON array, calls callback with results
|
||||
- `search_is_url(const char *input)` — heuristic: returns TRUE if input looks like a URL (has scheme, or has a dot with no spaces)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `src/settings.h` + `src/settings.c`
|
||||
- Add `char search_engine[64]` to `browser_settings_t` (default "duckduckgo")
|
||||
- Load/save the `search_engine` key from db_kv
|
||||
|
||||
### `src/db.h` + `src/db.c`
|
||||
- Add `db_history_search(const char *query, char ***urls_out, char ***titles_out, int *count_out, int limit)` — `WHERE url LIKE '%q%' OR title LIKE '%q%' ORDER BY visit_count DESC, visited_at DESC LIMIT ?`
|
||||
- Add index on `history(url)` and `history(title)` for search performance
|
||||
|
||||
### `src/tab_manager.c`
|
||||
- Add a `GtkListStore` (per-tab or shared) with columns: display text, URL, item type (direct/suggestion), icon name
|
||||
- Add `GtkEntryCompletion` to each tab's `url_entry` with a custom match function
|
||||
- Add `on_url_changed` callback — rebuilds the list store on each keystroke:
|
||||
1. Clear store
|
||||
2. Query history via `db_history_search()`
|
||||
3. Query bookmarks via `bookmarks_get_dirs()` + filter
|
||||
4. Add domain heuristic entries if applicable
|
||||
5. Fire async `search_suggest_fetch_async()` — on completion, append suggestion rows
|
||||
- Override `match-selected` signal — read the URL column; if it's a direct link, navigate; if it's a search suggestion, build search URL and navigate
|
||||
- Modify `on_url_activate` — if input is not a URL (per `search_is_url()`), build a search URL and navigate instead of prepending `https://`
|
||||
- Modify `normalize_url` — if input is not a URL and not an about: URL, treat as a search query
|
||||
|
||||
### `src/nostr_bridge.c`
|
||||
- Add a "Search Engine" section to the settings page HTML with a `<select>` dropdown of built-in engines
|
||||
- Add `search_engine` to the `handle_settings_set` key/value handler
|
||||
|
||||
### `src/settings_sync.c`
|
||||
- Add `"search_engine"` to `g_sync_setting_keys[]` whitelist
|
||||
|
||||
### `Makefile`
|
||||
- Add `src/search.c` to `SRC`
|
||||
|
||||
## Dropdown UX Design
|
||||
|
||||
Each row in the completion dropdown shows:
|
||||
- **Direct links** (history/bookmarks/domain): `🌐 wikipedia.org — Wikipedia` (URL + title), with a history/bookmark icon
|
||||
- **Search suggestions**: `🔍 wikipedia english` (search term), with a search icon
|
||||
|
||||
Direct links always appear before search suggestions. A visual separator (different icon, possibly different text color via Pango markup) distinguishes the two categories.
|
||||
|
||||
## Enter Key Behavior
|
||||
|
||||
When the user presses Enter without selecting a dropdown item:
|
||||
1. If `search_is_url(input)` is true → navigate to the URL (current behavior)
|
||||
2. If not → build a search URL from the active engine and navigate
|
||||
|
||||
This means typing "wikipedia" and pressing Enter goes to DDG search, but typing "wikipedia.org" and pressing Enter goes directly to wikipedia.org. Typing "wikipedia" and selecting the history entry for wikipedia.org from the dropdown goes directly there.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `src/search.h` + `src/search.c` (engine definitions, URL builders, async suggestion fetch, URL heuristic)
|
||||
2. Add `db_history_search()` to `src/db.h` + `src/db.c` + search indexes
|
||||
3. Add `search_engine` setting to `src/settings.h` + `src/settings.c`
|
||||
4. Add search engine dropdown to settings page in `src/nostr_bridge.c`
|
||||
5. Add `search_engine` to sync whitelist in `src/settings_sync.c`
|
||||
6. Implement URL bar dropdown in `src/tab_manager.c` (list store, entry completion, changed callback, match-selected handler)
|
||||
7. Update `on_url_activate` / `normalize_url` for search fallback
|
||||
8. Add `src/search.c` to `Makefile`
|
||||
9. Build and test
|
||||
293
plans/selective-tor-onion-routing.md
Normal file
293
plans/selective-tor-onion-routing.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Selective Tor Routing — .onion Only
|
||||
|
||||
## Goal
|
||||
|
||||
sovereign_browser should make all address types "just work":
|
||||
|
||||
- `http://` / `https://` → direct (clearnet)
|
||||
- `file://` → direct (local)
|
||||
- `.onion` → through Tor SOCKS proxy
|
||||
- `.fips` → through FIPS TUN interface
|
||||
- `nostr://` → direct to relays (or through Tor if relay is `.onion`)
|
||||
|
||||
Tor and FIPS are **enabled by default**. Tor provides a SOCKS proxy that
|
||||
is used **only** for `.onion` addresses. Regular internet traffic is
|
||||
never routed through Tor.
|
||||
|
||||
This is NOT Tor Browser. It's a browser where `.onion` addresses just
|
||||
work because Tor is running in the background.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
The current implementation (in `src/net_services.c`) sets WebKit's proxy
|
||||
to route **all** HTTP/HTTPS through Tor when Tor is enabled (fail-closed
|
||||
mode). This is the wrong default for sovereign_browser's vision — it
|
||||
would break clearnet access and isn't what we want.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Traffic Routing
|
||||
|
||||
| URL pattern | Route | How |
|
||||
|-------------|-------|-----|
|
||||
| `http://example.com` | Direct | WebKit default networking, no proxy |
|
||||
| `https://example.com` | Direct | WebKit default networking, no proxy |
|
||||
| `file:///path` | Direct | Local file access |
|
||||
| `http://something.onion` | Tor SOCKS | Custom scheme handler |
|
||||
| `https://something.onion` | Tor SOCKS | Custom scheme handler |
|
||||
| `http://npub1abc.fips/` | FIPS TUN | DNS resolves to fd00::/8, TUN routes |
|
||||
| `nostr://npub1...` | Direct to relays | nostr:// scheme handler |
|
||||
| `sovereign://...` | Direct (internal) | sovereign:// scheme handler |
|
||||
|
||||
### WebKit Proxy Configuration
|
||||
|
||||
**Always `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.** WebKit never uses a
|
||||
proxy for its default network stack. All proxying is done via custom
|
||||
URI scheme handlers.
|
||||
|
||||
This is a change from the current code, which sets `CUSTOM` mode with
|
||||
Tor's SOCKS endpoint. We remove that entirely.
|
||||
|
||||
### .onion Handling
|
||||
|
||||
#### Approach: Intercept + custom scheme
|
||||
|
||||
1. **`on_decide_policy` in `tab_manager.c`** intercepts navigation to
|
||||
`.onion` URLs (both `http://*.onion` and `https://*.onion`).
|
||||
|
||||
2. **Rewrite to `tor://` scheme:** The intercepted URL is rewritten:
|
||||
- `http://abc.onion/path?q=1` → `tor://abc.onion/path?q=1`
|
||||
- `https://abc.onion/path` → `tor://abc.onion/path`
|
||||
- The scheme handler knows to use Tor's SOCKS proxy.
|
||||
|
||||
3. **`tor://` URI scheme handler** (new `src/tor_scheme.c`):
|
||||
- Registered via `webkit_web_context_register_uri_scheme(ctx, "tor", ...)`
|
||||
- Parses the `.onion` hostname, port, path, query from the URI
|
||||
- Fetches the content via Tor's SOCKS proxy using libcurl with
|
||||
`CURLOPT_PROXY` set to Tor's SOCKS endpoint
|
||||
- Returns the response (headers + body) to WebKit via
|
||||
`webkit_uri_scheme_request_finish()`
|
||||
- Supports both HTTP and HTTPS over .onion (Tor handles the TLS)
|
||||
|
||||
4. **URL bar:** When the user types `abc.onion` in the URL bar,
|
||||
`normalize_url()` should recognize `.onion` and prepend `http://`
|
||||
(or `https://`), then the `on_decide_policy` interception handles
|
||||
the rest.
|
||||
|
||||
5. **Links on pages:** When a web page has `<a href="http://abc.onion">`,
|
||||
clicking it triggers `on_decide_policy`, which intercepts and
|
||||
rewrites to `tor://abc.onion`.
|
||||
|
||||
### Why a custom scheme handler instead of WebKit proxy?
|
||||
|
||||
WebKitGTK's proxy API (`WebKitNetworkProxySettings`) only supports
|
||||
"proxy everything, ignore these hosts" — not "proxy only these hosts."
|
||||
Setting a proxy for everything would route clearnet through Tor, which
|
||||
we don't want. A custom scheme handler gives us precise control: only
|
||||
`.onion` traffic goes through Tor.
|
||||
|
||||
The downside is that the custom scheme handler must handle HTTP
|
||||
semantics (GET, POST, headers, cookies, redirects) itself. However,
|
||||
libcurl handles all of this — we just pass the request to curl with
|
||||
the SOCKS proxy configured and return the response.
|
||||
|
||||
### libcurl for .onion fetches
|
||||
|
||||
The `tor://` scheme handler uses libcurl (already a dependency — used
|
||||
in `nostr_scheme.c` for NIP-11 fetches) with:
|
||||
|
||||
```c
|
||||
curl_easy_setopt(curl, CURLOPT_URL, "http://abc.onion/path?q=1");
|
||||
curl_easy_setopt(curl, CURLOPT_PROXY, "socks5h://127.0.0.1:9050");
|
||||
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
|
||||
```
|
||||
|
||||
`socks5h://` means curl sends the hostname to the SOCKS proxy (Tor
|
||||
resolves it — no DNS leak). `CURLPROXY_SOCKS5_HOSTNAME` ensures the
|
||||
.onion hostname is resolved by Tor, not locally.
|
||||
|
||||
For HTTPS over .onion, curl handles TLS normally — Tor just provides
|
||||
the transport.
|
||||
|
||||
### Request handling
|
||||
|
||||
The scheme handler must handle:
|
||||
|
||||
1. **GET requests** — fetch URL via curl, return body + content type
|
||||
2. **POST requests** — WebKit custom scheme handlers don't directly
|
||||
expose POST bodies. This is a known WebKitGTK limitation. For Phase 1,
|
||||
POST to .onion may not work (GET is sufficient for most .onion sites
|
||||
and Nostr relay web interfaces). A future enhancement could use a
|
||||
WebExtension for POST body access.
|
||||
3. **Redirects** — curl follows redirects by default
|
||||
(`CURLOPT_FOLLOWLOCATION`). The final URL is returned.
|
||||
4. **Headers** — curl captures response headers. We pass through
|
||||
Content-Type so WebKit renders correctly (HTML, JSON, images, etc.).
|
||||
5. **Cookies** — .onion cookies are handled by curl's cookie engine
|
||||
(separate from WebKit's cookie jar). For Phase 1, this is acceptable.
|
||||
Future: share cookies between WebKit and the Tor handler.
|
||||
6. **Timeouts** — 30 second timeout for .onion (Tor can be slow).
|
||||
|
||||
### Async handling
|
||||
|
||||
Like `nostr_scheme.c`, the `tor://` handler uses `GTask` to run the
|
||||
curl fetch on a worker thread and finish the WebKit request on the main
|
||||
context. This prevents blocking the UI.
|
||||
|
||||
### Tor SOCKS endpoint
|
||||
|
||||
The Tor SOCKS endpoint is determined by the existing Tor service
|
||||
management in `net_services.c` / `tor_control.c`:
|
||||
|
||||
- **Attached Tor:** use the discovered SOCKS endpoint
|
||||
(e.g., `socks5://127.0.0.1:9050`)
|
||||
- **Managed Tor:** use the private Unix socket
|
||||
(e.g., `socks5://unix:/home/user/.sovereign_browser/tor/socks.sock`)
|
||||
|
||||
The `tor://` scheme handler queries `net_service_get_status(NET_SERVICE_TOR)`
|
||||
to get the current SOCKS endpoint. If Tor is not ready, it returns an
|
||||
error page.
|
||||
|
||||
### FIPS .onion relays
|
||||
|
||||
If a Nostr relay URL is `wss://relay.onion`, the C-side relay fetch
|
||||
code (`relay_fetch.c`) would need to connect through Tor's SOCKS proxy.
|
||||
This is a future enhancement — the nostr_core_lib relay client would
|
||||
need SOCKS support. For Phase 1, only WebKit .onion browsing is
|
||||
supported.
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Change WebKit proxy to always NO_PROXY
|
||||
|
||||
In `src/net_services.c`, `net_services_refresh_proxy()`:
|
||||
|
||||
- **Remove** the current logic that sets `CUSTOM` proxy mode with Tor's
|
||||
SOCKS endpoint.
|
||||
- **Always** set `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.
|
||||
- Tor proxying is now handled entirely by the `tor://` scheme handler,
|
||||
not by WebKit's proxy settings.
|
||||
- Keep the function (it's called from various places) but simplify it
|
||||
to always set NO_PROXY. Or remove the calls and just set NO_PROXY
|
||||
once at startup.
|
||||
|
||||
### 2. Create `src/tor_scheme.c` / `src/tor_scheme.h`
|
||||
|
||||
New URI scheme handler for `tor://`:
|
||||
|
||||
```c
|
||||
void tor_scheme_register(WebKitWebContext *ctx);
|
||||
```
|
||||
|
||||
- Registers the `tor` URI scheme
|
||||
- On request: parse the .onion URL, get Tor SOCKS endpoint from
|
||||
`net_service_get_status(NET_SERVICE_TOR)`, fetch via libcurl with
|
||||
SOCKS proxy, return response to WebKit
|
||||
- Async via `GTask` (same pattern as `nostr_scheme.c`)
|
||||
- Returns error JSON if Tor is not ready
|
||||
|
||||
### 3. Intercept .onion in `on_decide_policy`
|
||||
|
||||
In `src/tab_manager.c`, `on_decide_policy()`:
|
||||
|
||||
```c
|
||||
/* Check if the navigation target is a .onion URL */
|
||||
if (uri && (g_str_has_suffix(host, ".onion"))) {
|
||||
/* Rewrite to tor:// scheme */
|
||||
char *tor_url = g_strdup_printf("tor://%s", uri + strlen("http://"));
|
||||
/* or preserve https:// → tor:// */
|
||||
webkit_web_view_load_uri(webview, tor_url);
|
||||
g_free(tor_url);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
return TRUE;
|
||||
}
|
||||
```
|
||||
|
||||
Also intercept in `normalize_url()` so the URL bar shows the original
|
||||
`http://abc.onion` form (not `tor://abc.onion`).
|
||||
|
||||
### 4. Register tor:// scheme in `main.c`
|
||||
|
||||
```c
|
||||
tor_scheme_register(web_ctx);
|
||||
```
|
||||
|
||||
### 5. Enable Tor and FIPS by default
|
||||
|
||||
In `src/settings.c`, change defaults:
|
||||
|
||||
```c
|
||||
s->tor_enabled = TRUE; /* was FALSE */
|
||||
s->fips_enabled = TRUE; /* was FALSE */
|
||||
```
|
||||
|
||||
### 6. Update Makefile
|
||||
|
||||
Add `src/tor_scheme.c` to `SRC`.
|
||||
|
||||
### 7. Update `net_services_refresh_proxy()`
|
||||
|
||||
Simplify to always set NO_PROXY. Remove the fail-closed logic (no
|
||||
longer needed — clearnet always works, .onion goes through the scheme
|
||||
handler).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── tor_scheme.c # tor:// URI scheme handler (new)
|
||||
├── tor_scheme.h # Public API (new)
|
||||
├── net_services.c # Simplified: always NO_PROXY
|
||||
├── tab_manager.c # .onion interception in on_decide_policy + normalize_url
|
||||
├── main.c # Register tor:// scheme
|
||||
├── settings.c # Tor/FIPS enabled by default
|
||||
└── Makefile # Add tor_scheme.c
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: .onion browsing via tor:// scheme
|
||||
1. Create `tor_scheme.c` — curl-based .onion fetcher with SOCKS proxy
|
||||
2. Register `tor://` scheme in `main.c`
|
||||
3. Intercept `.onion` in `on_decide_policy` and `normalize_url`
|
||||
4. Simplify `net_services_refresh_proxy()` to always NO_PROXY
|
||||
5. Enable Tor and FIPS by default in settings
|
||||
6. Test: `http://duckduckgogg42tjsool4r5mn3r2onion.onion` loads through Tor
|
||||
|
||||
### Phase 2: Polish
|
||||
1. Handle .onion redirects (curl follows, but URL bar should show final)
|
||||
2. Error pages for .onion when Tor is not ready
|
||||
3. Cookie handling for .onion (curl cookie jar)
|
||||
4. Timeout handling (30s for .onion)
|
||||
5. Content-type passthrough (HTML, JSON, images, CSS, JS)
|
||||
|
||||
### Phase 3: .onion Nostr relays (future)
|
||||
1. Add SOCKS proxy support to relay fetch code
|
||||
2. Allow `wss://relay.onion` in relay configuration
|
||||
3. Route .onion relay connections through Tor
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build and start browser
|
||||
2. Verify `http://example.com` loads directly (not through Tor)
|
||||
3. Verify `file:///path` works
|
||||
4. If Tor binary is available:
|
||||
- Verify `http://something.onion` loads through Tor
|
||||
- Verify Tor log shows the .onion connection
|
||||
5. If Tor binary is not available:
|
||||
- Verify .onion URLs show "Tor not ready" error
|
||||
- Verify clearnet still works fine
|
||||
6. Verify FIPS .fips URLs still work (if FIPS is available)
|
||||
7. Verify Tor and FIPS are enabled by default on fresh install
|
||||
124
plans/service-workers-on-file.md
Normal file
124
plans/service-workers-on-file.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Service Workers on file:// — Analysis & Options
|
||||
|
||||
## The Problem
|
||||
|
||||
`navigator.serviceWorker.register()` on a `file://` page rejects with:
|
||||
> "serviceWorker.register() must be called with a script URL whose protocol is either HTTP or HTTPS"
|
||||
|
||||
## Where the Check Lives
|
||||
|
||||
The error message is generated **inside WebKit's web process** (the
|
||||
JavaScript engine / WebCore layer), not in the WebKitGTK C API layer.
|
||||
The check is in WebKit's `ServiceWorkerContainer::register()` C++ code
|
||||
(in `Source/WebCore/workers/service/ServiceWorkerContainer.cpp`), which
|
||||
validates the script URL protocol against a hard-coded list of
|
||||
`http` and `https`.
|
||||
|
||||
This is **not** controllable via:
|
||||
- `WebKitSettings` (no setting for SW protocol allowlist)
|
||||
- `WebKitSecurityManager` (only controls local/secure/cors/empty/no-access
|
||||
scheme classification — not SW registration eligibility)
|
||||
- `WebKitWebsiteDataManager` (only controls the storage *directory* for SW
|
||||
registrations, not the protocol gate)
|
||||
|
||||
## Options
|
||||
|
||||
### Option A: Patch WebKit (fork / rebuild) — HIGH EFFORT
|
||||
|
||||
We could fork `webkitgtk` (or maintain a patch set) that removes or
|
||||
relaxes the protocol check in `ServiceWorkerContainer.cpp`. This is the
|
||||
only way to make `navigator.serviceWorker.register()` accept `file://`
|
||||
URLs natively.
|
||||
|
||||
**Pros:**
|
||||
- Fully native — JS code works unchanged
|
||||
- SW lifecycle (install/activate/fetch events) works as designed
|
||||
|
||||
**Cons:**
|
||||
- Requires maintaining a WebKit fork or patch set against Debian's
|
||||
`webkit2gtk` package
|
||||
- Every WebKitGTK update requires re-applying the patch
|
||||
- Build complexity — WebKit is ~35MB of system lib; rebuilding from
|
||||
source is heavy
|
||||
- The SW spec assumes HTTP semantics (scope, update checks, fetch
|
||||
interception) that don't map cleanly to `file://`
|
||||
|
||||
**Verdict:** Too heavy for the benefit. Service Workers on `file://` is a
|
||||
niche use case.
|
||||
|
||||
### Option B: Register file:// as a "secure" scheme — ALREADY DONE, DOESN'T HELP
|
||||
|
||||
We already call `webkit_security_manager_register_uri_scheme_as_secure()`
|
||||
for `file` in `src/main.c:850`. This makes `file://` origins treated as
|
||||
"potentially trustworthy" for some purposes (like `navigator.geolocation`
|
||||
or `getUserMedia`), but **does not** bypass the SW registration protocol
|
||||
check — that check is a separate hard-coded `http/https` test in
|
||||
WebCore.
|
||||
|
||||
### Option C: Use a custom URI scheme instead of file:// — MODERATE EFFORT
|
||||
|
||||
Instead of loading local sites via `file:///path/to/index.html`, we could
|
||||
register a custom scheme (e.g. `local://`) via
|
||||
`webkit_web_context_register_uri_scheme()` that serves files from disk.
|
||||
If we also register `local://` as secure via `WebKitSecurityManager`, SW
|
||||
registration *might* work — but only if WebKit's SW code checks
|
||||
"potentially trustworthy origin" rather than hard-coding `http/https`.
|
||||
This needs testing.
|
||||
|
||||
**Pros:**
|
||||
- No WebKit fork needed
|
||||
- We control the scheme handler entirely
|
||||
- Could also solve the Cache API issue (Issue #5)
|
||||
|
||||
**Cons:**
|
||||
- Changes the URL model — users see `local://` instead of `file://`
|
||||
- Relative path resolution may differ
|
||||
- Still might not work if WebKit's SW check is truly hard-coded to
|
||||
`http/https` (need to test)
|
||||
- Significant implementation effort (scheme handler, path mapping,
|
||||
security registration)
|
||||
|
||||
**Verdict:** Worth investigating if SW on local files becomes important.
|
||||
The first step would be a quick test: register `local://` as secure,
|
||||
load a page, try `navigator.serviceWorker.register()`.
|
||||
|
||||
### Option D: JavaScript shim / polyfill — LOW EFFORT, PARTIAL
|
||||
|
||||
We could inject a JS shim that overrides `navigator.serviceWorker.register`
|
||||
to do something useful on `file://` — e.g., load the SW script as a Web
|
||||
Worker instead, or implement a minimal fetch interceptor.
|
||||
|
||||
**Pros:**
|
||||
- No WebKit changes
|
||||
- We already inject JS shims (`window.nostr`)
|
||||
|
||||
**Cons:**
|
||||
- Not a real Service Worker — no `fetch` event interception, no
|
||||
`install`/`activate` lifecycle, no `clients` API
|
||||
- Would only provide a subset of SW functionality
|
||||
- Complex to implement convincingly
|
||||
|
||||
**Verdict:** Only useful if someone specifically needs SW-like behavior
|
||||
on `file://` and is willing to accept a polyfill.
|
||||
|
||||
### Option E: Do nothing — RECOMMENDED
|
||||
|
||||
Service Workers are designed for HTTP/HTTPS origins. On `file://`:
|
||||
- **Web Workers** work perfectly (tested ✅) — use those for background
|
||||
computation
|
||||
- **Shared Workers** also work perfectly (tested ✅) — use those for
|
||||
cross-tab/cross-page shared background computation
|
||||
- **IndexedDB** works perfectly (tested ✅) — use that for offline
|
||||
storage
|
||||
- **Cache API** doesn't work (Issue #5) — but local files are already
|
||||
local, so caching is redundant
|
||||
- **fetch interception** (the main SW use case) is less relevant when
|
||||
everything is local
|
||||
|
||||
The "deprecated web security" design goal is about **cross-origin
|
||||
access** (CORS/SOP), which works. Service Workers are a different
|
||||
concern — they're about **offline web apps and PWA features**, which
|
||||
are inherently HTTP-oriented concepts.
|
||||
|
||||
**Verdict:** Document the limitation and move on. If a specific use case
|
||||
emerges that needs SW on `file://`, revisit Option C (custom scheme).
|
||||
167
plans/sqlite-migration.md
Normal file
167
plans/sqlite-migration.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plan: Migrate All Storage to SQLite + Remove Key Persistence
|
||||
|
||||
## Overview
|
||||
|
||||
Consolidate all browser data storage into the SQLite database (`~/.sovereign_browser/browser.db`) and **remove all key persistence** — private keys live only in RAM and are gone when the browser quits.
|
||||
|
||||
## Current state
|
||||
|
||||
| File | Format | Stores | Action |
|
||||
|------|--------|--------|--------|
|
||||
| `identity.json` | JSON | Nostr private key, mnemonic, bunker URL | **Delete the code + file** — keys must never touch disk |
|
||||
| `history.txt` | Plain text | Recent URLs (max 50) | **Migrate to SQLite** `history` table |
|
||||
| `session.txt` | Plain text | Open tab URLs | **Migrate to SQLite** `session` table |
|
||||
| `settings.conf` | key=value | Browser preferences | **Migrate to SQLite** `key_value` table (already exists) |
|
||||
| `browser.db` | SQLite | Nostr events, tags, key_value | **Keep + extend** |
|
||||
|
||||
## Phase 1: Remove key persistence
|
||||
|
||||
### `src/key_store.h` / `src/key_store.c`
|
||||
|
||||
**Remove:**
|
||||
- `key_store_path()` — no file path needed
|
||||
- `key_store_save()` — never called (dead code)
|
||||
- `key_store_load()` — never called (dead code)
|
||||
- `key_store_clear()` — replace with a no-op or remove the calls
|
||||
|
||||
**Keep:**
|
||||
- `key_store_identity_t` struct (in-memory only)
|
||||
- `key_store_method_t` enum
|
||||
- `key_store_create_signer()` — creates a signer from an in-memory identity
|
||||
|
||||
### `src/cli.h` / `src/cli.c`
|
||||
|
||||
**Remove:**
|
||||
- `--no-save-identity` flag and `no_save_identity` field (meaningless now)
|
||||
- The TODO comment about `key_store_save()`
|
||||
|
||||
### `src/main.c` / `src/agent_login.c`
|
||||
|
||||
**Remove:**
|
||||
- `key_store_clear()` calls in `app_menu_logout_proxy()` and `agent_login.c` logout
|
||||
|
||||
### Defensive cleanup
|
||||
|
||||
On startup, delete `~/.sovereign_browser/identity.json` if it exists (in case a previous version created it).
|
||||
|
||||
## Phase 2: Migrate history to SQLite
|
||||
|
||||
### `src/db.h` / `src/db.c`
|
||||
|
||||
Add a `history` table to the schema:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
visited_at INTEGER NOT NULL,
|
||||
visit_count INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_visited_at ON history(visited_at DESC);
|
||||
```
|
||||
|
||||
Add functions:
|
||||
```c
|
||||
int db_history_add(const char *url, const char *title);
|
||||
/* Returns most-recent-first. Fills urls_out (caller frees each + array). */
|
||||
char **db_history_get(int *count_out, int limit);
|
||||
int db_history_clear(void);
|
||||
```
|
||||
|
||||
`db_history_add` does an UPSERT: on conflict (URL already exists), increment `visit_count` and update `visited_at`.
|
||||
|
||||
### `src/history.h` / `src/history.c`
|
||||
|
||||
Rewrite to use `db_history_add` / `db_history_get` / `db_history_clear`. Remove the flat-file `history_path()`, `fopen()`, the `g_history[50][2048]` array, and `history_load()`. The `history_add()` function now takes an optional title parameter (or we keep the existing signature and pass NULL for title from `on_load_changed`).
|
||||
|
||||
Remove `HISTORY_MAX_ENTRIES` (no cap — SQLite handles it).
|
||||
|
||||
## Phase 3: Migrate session to SQLite
|
||||
|
||||
### `src/db.h` / `src/db.c`
|
||||
|
||||
Add a `session` table:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
tab_index INTEGER PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Add functions:
|
||||
```c
|
||||
int db_session_save(const char **urls, const char **titles, int count);
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
int db_session_clear(void);
|
||||
```
|
||||
|
||||
`db_session_save` clears the table then inserts all current tabs. `db_session_load` reads them back in tab_index order.
|
||||
|
||||
### `src/session.h` / `src/session.c`
|
||||
|
||||
Rewrite `session_save()` and `session_restore()` to use the SQLite functions. Remove `session_path()` and `fopen()`.
|
||||
|
||||
## Phase 4: Migrate settings to SQLite
|
||||
|
||||
### `src/settings.h` / `src/settings.c`
|
||||
|
||||
Rewrite `settings_load()` and `settings_save()` to use `db_kv_get` / `db_kv_set` for each field. The `key_value` table already exists in the schema.
|
||||
|
||||
Each setting is stored as a key-value pair:
|
||||
- `restore_session` → `"true"` / `"false"`
|
||||
- `new_tab_url` → the URL string
|
||||
- `tab_bar_position` → `"top"` / `"bottom"` / etc.
|
||||
- `bootstrap_relays` → newline-separated URLs
|
||||
- etc.
|
||||
|
||||
**Important:** `settings_load()` must be called **after** `db_init()` (the DB must be open first). This changes the startup order in `main.c`:
|
||||
```
|
||||
settings_load() → db_init() → settings_load() (revised order)
|
||||
```
|
||||
Actually: `db_init()` first, then `settings_load()` reads from the DB.
|
||||
|
||||
### Remove `settings_path()` and `fopen()` from `settings.c`.
|
||||
|
||||
## Phase 5: Cleanup
|
||||
|
||||
- Delete `~/.sovereign_browser/identity.json` on startup (defensive)
|
||||
- Delete `~/.sovereign_browser/history.txt` on startup (one-time migration)
|
||||
- Delete `~/.sovereign_browser/session.txt` on startup (one-time migration)
|
||||
- Delete `~/.sovereign_browser/settings.conf` on startup (one-time migration)
|
||||
- Or: leave the old files in place (they're just ignored) — simpler, less destructive
|
||||
|
||||
## Startup order change in `main.c`
|
||||
|
||||
Current:
|
||||
```
|
||||
settings_load();
|
||||
history_load();
|
||||
db_init();
|
||||
```
|
||||
|
||||
New:
|
||||
```
|
||||
db_init(); /* open the database first */
|
||||
settings_load(); /* reads from key_value table */
|
||||
/* history_load() removed — history is queried from SQLite on demand */
|
||||
```
|
||||
|
||||
## File change summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/key_store.h` | Remove `key_store_save`, `key_store_load`, `key_store_clear`, `key_store_path` |
|
||||
| `src/key_store.c` | Remove file I/O functions; keep only `key_store_create_signer` |
|
||||
| `src/db.h` | Add `db_history_*`, `db_session_*` functions |
|
||||
| `src/db.c` | Add `history` + `session` tables to schema; implement new functions |
|
||||
| `src/history.h` | Update API (remove `history_load`, add title param) |
|
||||
| `src/history.c` | Rewrite to use SQLite; remove flat-file code |
|
||||
| `src/session.h` | No API change |
|
||||
| `src/session.c` | Rewrite to use SQLite; remove flat-file code |
|
||||
| `src/settings.h` | No API change |
|
||||
| `src/settings.c` | Rewrite to use `db_kv_get`/`db_kv_set`; remove flat-file code |
|
||||
| `src/main.c` | Reorder startup (`db_init` before `settings_load`); remove `key_store_clear` call; delete old files defensively |
|
||||
| `src/agent_login.c` | Remove `key_store_clear` call |
|
||||
| `src/cli.h` | Remove `no_save_identity` field |
|
||||
| `src/cli.c` | Remove `--no-save-identity` flag |
|
||||
181
plans/system-prompt-as-skill.md
Normal file
181
plans/system-prompt-as-skill.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# System Prompt as the Default Sovereign Browser Skill
|
||||
|
||||
## Goal
|
||||
|
||||
The system prompt on `sovereign://agents` is essentially the default skill for
|
||||
sovereign_browser. Instead of having a separate "System Prompt" field, we
|
||||
treat it as an **unsaved skill** that the user can edit, save, and share —
|
||||
matching how ai.html handles skills.
|
||||
|
||||
## Current state
|
||||
|
||||
- `sovereign://agents` (config page) has a "System Prompt" textarea saved to
|
||||
`sovereign_browser.agent.system_prompt` in the `d:user-settings` event.
|
||||
- `sovereign://agents/chat` has a skills list (kind 31123) with checkboxes.
|
||||
- The agent loop (`agent_loop.c`) uses the system prompt from settings, OR the
|
||||
concatenated templates of selected skills (if any skills are selected).
|
||||
|
||||
## Desired state
|
||||
|
||||
- **Rename "System Prompt" to "Sovereign Browser Skill"** on the config page.
|
||||
- The system prompt is presented as an **unsaved skill** — a skill that exists
|
||||
locally but hasn't been published to Nostr yet.
|
||||
- In the chat page, the "Sovereign Browser Skill" appears in the skills list
|
||||
as an unsaved skill (with a "Save" button to publish it as kind 31123).
|
||||
- The user can **edit** the skill (name, description, template, tools) inline,
|
||||
matching ai.html's `renderSkillsEditor()` pattern.
|
||||
- The user can **save** the skill to Nostr (publishes kind 31123), making it
|
||||
available to other apps (client, didactyl).
|
||||
- The user can **modify** saved skills the same way (if they're the author).
|
||||
|
||||
## Design
|
||||
|
||||
### On `sovereign://agents` (config page)
|
||||
|
||||
Replace the "System Prompt" section with a "Sovereign Browser Skill" section:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Sovereign Browser Skill │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Name: [Sovereign Browser Default ] │
|
||||
│ Description: [Default agent skill ] │
|
||||
│ Template: │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ You are an AI assistant embedded in │ │
|
||||
│ │ a web browser. You have access to... │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ Requires tools: [browser, fs, shell ] │
|
||||
│ │
|
||||
│ [💾 Save as Skill] (publishes to Nostr) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- The template is the current system prompt value.
|
||||
- "Save as Skill" publishes a kind 31123 event with the skill content.
|
||||
- The skill is saved to `sovereign_browser.agent.system_prompt` locally (as
|
||||
now) AND optionally published to Nostr as a skill.
|
||||
|
||||
### On `sovereign://agents/chat` (chat page)
|
||||
|
||||
The skills list shows:
|
||||
1. **Sovereign Browser Skill** (unsaved) — always at the top, with a
|
||||
checkbox (selected by default), an edit button, and a "Save" button.
|
||||
2. **Saved skills** (kind 31123) — fetched from Nostr, with checkboxes,
|
||||
edit buttons (if user-authored), and delete buttons.
|
||||
|
||||
When the user selects the Sovereign Browser Skill, its template is used as
|
||||
the system prompt (concatenated with any other selected skills, matching the
|
||||
existing `agent_skills_build_system_prompt()` logic).
|
||||
|
||||
### Skill editor (matching ai.html)
|
||||
|
||||
When the user clicks "Edit" on a skill (or the Sovereign Browser Skill), an
|
||||
inline editor expands (like ai.html's `renderSkillsEditor()`):
|
||||
|
||||
```
|
||||
▼ Sovereign Browser Default
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Name: [Sovereign Browser Default ] │
|
||||
│ Description: [Default agent skill ] │
|
||||
│ Template: │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ You are an AI assistant embedded...│ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ Requires tools: [browser, fs, shell ] │
|
||||
│ │
|
||||
│ [💾 Save / Update] [Cancel] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- For the Sovereign Browser Skill (unsaved): "Save" publishes it to Nostr as
|
||||
a new kind 31123 event AND updates the local system prompt.
|
||||
- For saved skills (user-authored): "Save / Update" re-publishes the kind
|
||||
31123 event with the edited content.
|
||||
- For saved skills (not user-authored): "View Only (not your skill)" — no
|
||||
edit, matching ai.html's `canSave` logic.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — Rename + restructure the config page
|
||||
|
||||
1. **`www/agents/config.html` + `config.js`** — Replace the "System Prompt"
|
||||
section with a "Sovereign Browser Skill" section with Name, Description,
|
||||
Template, and Requires Tools fields. Add a "Save as Skill" button.
|
||||
|
||||
2. **`src/nostr_bridge.c`** — Update `handle_agents_set()` to handle the new
|
||||
skill fields (`agent.skill_name`, `agent.skill_description`,
|
||||
`agent.skill_template`, `agent.skill_requires_tools`). These are saved to
|
||||
`sovereign_browser.agent` in the `d:user-settings` event (replacing the
|
||||
old `system_prompt` field).
|
||||
|
||||
3. **`src/agent_loop.c`** — Update the system prompt logic: use
|
||||
`sovereign_browser.agent.skill_template` as the default system prompt
|
||||
(instead of `system_prompt`). If skills are selected, concatenate their
|
||||
templates (as now).
|
||||
|
||||
### Phase 2 — Show the Sovereign Browser Skill in the chat page
|
||||
|
||||
1. **`www/agents/chat.js`** — In `renderSkillList()`, add the Sovereign
|
||||
Browser Skill at the top of the list as an "unsaved" skill. It has:
|
||||
- A checkbox (selected by default)
|
||||
- The skill name (from `sovereign_browser.agent.skill_name`)
|
||||
- An "Edit" button that opens the inline editor
|
||||
- A "Save" button that publishes it to Nostr
|
||||
|
||||
2. **`src/agent_skills.c`** — Add a function
|
||||
`agent_skills_get_default()` that returns the Sovereign Browser Skill
|
||||
from the local settings (not from Nostr). This is used by the chat page
|
||||
to show the unsaved skill.
|
||||
|
||||
3. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/default`
|
||||
endpoint that returns the Sovereign Browser Skill from settings.
|
||||
|
||||
### Phase 3 — Skill editor in the chat page
|
||||
|
||||
1. **`www/agents/chat.js`** — Implement `renderSkillEditor(skill)` matching
|
||||
ai.html's `renderSkillsEditor()`:
|
||||
- Expandable `<details>` for each selected skill
|
||||
- Name, Description, Template, Requires Tools fields
|
||||
- "Save / Update" button (for user-authored or unsaved skills)
|
||||
- "View Only" for skills not authored by the user
|
||||
|
||||
2. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/update`
|
||||
endpoint that re-publishes an existing skill (kind 31123) with edited
|
||||
content. For the Sovereign Browser Skill, this also updates the local
|
||||
settings.
|
||||
|
||||
3. **`src/agent_skills.c`** — Add `agent_skills_update(d_tag, name,
|
||||
description, content, requires_tools)` that fetches the existing skill
|
||||
event, updates its content, re-signs, and re-publishes.
|
||||
|
||||
### Phase 4 — Wire the default skill into the agent loop
|
||||
|
||||
1. **`src/agent_loop.c`** — The system prompt is now:
|
||||
- If the Sovereign Browser Skill is selected (default): use its template
|
||||
- If other skills are selected: concatenate their templates
|
||||
- If no skills selected: use the Sovereign Browser Skill template as
|
||||
fallback (it's always the default)
|
||||
|
||||
2. **`src/agent_skills.c`** — Update `agent_skills_build_system_prompt()` to
|
||||
always include the Sovereign Browser Skill template (either as the base
|
||||
prompt, or concatenated with selected skills).
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `www/agents/config.html` | Rename "System Prompt" to "Sovereign Browser Skill", restructure fields |
|
||||
| `www/agents/config.js` | Handle new skill fields, "Save as Skill" button |
|
||||
| `www/agents/chat.js` | Show Sovereign Browser Skill in skills list, implement skill editor |
|
||||
| `www/agents/chat.css` | Style the skill editor (matching ai.html) |
|
||||
| `src/nostr_bridge.c` | New endpoints: `skills/default`, `skills/update`; update `handle_agents_set()` |
|
||||
| `src/agent_skills.c` / `.h` | `agent_skills_get_default()`, `agent_skills_update()` |
|
||||
| `src/agent_loop.c` | Use skill template as system prompt |
|
||||
| `src/settings.h` / `settings.c` | Rename `agent_llm_system_prompt` to `agent_skill_template`, add `agent_skill_name`, `agent_skill_description`, `agent_skill_requires_tools` |
|
||||
|
||||
## Reference
|
||||
|
||||
- `~/lt/client/www/ai.html` lines 1454-1540 — `renderSkillsEditor()` pattern
|
||||
- `~/lt/client/www/ai.html` lines 1534-1538 — "Save / Update Skill" button
|
||||
- `~/lt/client/www/ai.html` lines 1471 — `canSave` logic (only author can edit)
|
||||
224
plans/webkit-data-isolation.md
Normal file
224
plans/webkit-data-isolation.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# WebKit Website Data Isolation Per Nostr Identity
|
||||
|
||||
**Status: IMPLEMENTED (Phase 0 + Phase A, verified end-to-end).**
|
||||
|
||||
## Problem
|
||||
|
||||
When a user logs in, visits a page, logs out, then logs in as a different
|
||||
identity and revisits the same page, the second user sees the first user's
|
||||
data (session cookies, localStorage, cached responses, service workers).
|
||||
|
||||
## Root Cause
|
||||
|
||||
The browser uses the **default** [`WebKitWebContext`](src/main.c:977) and its
|
||||
attached [`WebKitWebsiteDataManager`](src/main.c:993) for the entire process
|
||||
lifetime. That single data manager holds, process-wide:
|
||||
|
||||
- HTTP disk + memory cache
|
||||
- Cookies (the primary leak — session cookies identify you to web pages)
|
||||
- localStorage / sessionStorage / IndexedDB / WebSQL
|
||||
- Service worker registrations + offline app cache
|
||||
- Favicon database ([`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003))
|
||||
|
||||
The logout / identity-switch paths only clear the Nostr signer and do **nothing**
|
||||
to WebKit's data stores or to the already-loaded tabs:
|
||||
|
||||
- [`app_menu_logout_proxy`](src/main.c:186) — frees `g_state.signer`, blanks pubkey, returns.
|
||||
- [`agent_logout`](src/agent_login.c:485) — `app_clear_signer()` + `nostr_bridge_set_signer(NULL, ...)`.
|
||||
- [`agent_switch_identity`](src/agent_login.c:492) — frees old signer, logs in new, no tab reload.
|
||||
|
||||
Two compounding problems:
|
||||
|
||||
1. **Persistent WebKit data** survives the identity switch.
|
||||
2. **Live tabs** keep user A's DOM/localStorage in memory; wiping the data
|
||||
manager alone does not clear already-loaded pages.
|
||||
|
||||
The existing [`plans/per-user-profiles.md`](plans/per-user-profiles.md) isolates
|
||||
the SQLite layer (bookmarks, history, session, Nostr events) but does **not**
|
||||
cover WebKit's own storage — this plan closes that gap.
|
||||
|
||||
## Solution (chosen: per-user data manager + clear-on-switch safety net)
|
||||
|
||||
### Part A — Per-user `WebKitWebsiteDataManager`
|
||||
|
||||
Give each pubkey its own data manager rooted at
|
||||
`~/.sovereign_browser/profiles/<pubkey_hex>/webkit/` so cookies, cache, storage,
|
||||
service workers, and favicons persist per-user and never cross-contaminate.
|
||||
|
||||
WebKitGTK constraint: a `WebKitWebContext` is bound to **one**
|
||||
`WebKitWebsiteDataManager` for its lifetime — you cannot swap the data manager
|
||||
on an existing context. Two viable strategies:
|
||||
|
||||
**Strategy 1 (preferred): per-user `WebKitWebContext`.**
|
||||
Create a fresh `webkit_web_context_new_with_website_data_manager(dm)` for each
|
||||
login. All tabs/webviews are created from this context. On logout/switch,
|
||||
destroy the old context (which tears down its webviews) and build a new one.
|
||||
This is the cleanest isolation and matches how Epiphary/GNOME Web does
|
||||
per-profile isolation.
|
||||
|
||||
**Strategy 2 (fallback): single context, manual clear + per-user dirs.**
|
||||
Keep the default context but on each login call
|
||||
`webkit_website_data_manager_clear(dm, WEBKIT_WEBSITE_DATA_ALL, 0, NULL, NULL, NULL)`
|
||||
then point the cookie/cache/storage dirs at the per-user path. This is fragile
|
||||
because the context caches some state internally and the favicon DB directory
|
||||
is set once at context init.
|
||||
|
||||
Recommend **Strategy 1**. It requires reworking how the context is created and
|
||||
how `tab_manager` / `nostr_bridge` / `tor_scheme` / `nostr_scheme` / `net_services`
|
||||
reference it (they currently call `webkit_web_context_get_default()` or receive
|
||||
the context at init), but it gives true isolation.
|
||||
|
||||
### Part B — Clear-on-switch safety net
|
||||
|
||||
Even with per-user data managers, the **live tabs** (and the agent chat sidebar
|
||||
webview) hold user A's DOM/localStorage in memory. On any identity change:
|
||||
|
||||
1. Close all tabs: `tab_manager_close_all()` ([`tab_manager.c:3727`](src/tab_manager.c)).
|
||||
2. Destroy the agent chat sidebar webview if present.
|
||||
3. (Strategy 1) Destroy the old `WebKitWebContext` + data manager.
|
||||
4. Build the new per-user context + data manager.
|
||||
5. Re-register URI schemes (`sovereign://`, `nostr://`, `tor://`) on the new
|
||||
context — they are per-context.
|
||||
6. Re-init `nostr_bridge`, `nostr_scheme`, `tor_scheme`, `net_services` against
|
||||
the new context.
|
||||
7. Re-init `tab_manager` against the new context (or expose a
|
||||
`tab_manager_set_context()`).
|
||||
8. Restore the new user's session (`session_restore()`) or open the default
|
||||
new-tab URL.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Login complete - pubkey known] --> B[profile_ensure_dir pubkey]
|
||||
B --> C[Build per-user WebKitWebsiteDataManager<br/>rooted at profiles/pubkey/webkit]
|
||||
C --> D[Build per-user WebKitWebContext<br/>new_with_website_data_manager]
|
||||
D --> E[Register sovereign/nostr/tor schemes on new ctx]
|
||||
E --> F[Re-init nostr_bridge, net_services, tab_manager on new ctx]
|
||||
F --> G[session_restore or open new-tab URL]
|
||||
|
||||
H[Logout / switch_identity] --> I[tab_manager_close_all]
|
||||
I --> J[Destroy sidebar webview]
|
||||
J --> K[Destroy old WebKitWebContext + data manager]
|
||||
K --> L{switch or logout?}
|
||||
L -->|switch| A
|
||||
L -->|logout| M[Show login dialog / wait for agent login]
|
||||
M --> A
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Profile helpers for WebKit data dir
|
||||
In [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) add:
|
||||
- `profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz)`
|
||||
→ `~/.sovereign_browser/profiles/<pubkey>/webkit/`
|
||||
- `profile_ensure_webkit_dir(const char *pubkey_hex)` — mkdir -p.
|
||||
|
||||
### 2. New module `src/web_context.c` / `src/web_context.h`
|
||||
Centralize context + data manager construction so login/logout/switch share
|
||||
one code path. Functions:
|
||||
- `web_context_build_for_pubkey(const char *pubkey_hex)` → returns a new
|
||||
`WebKitWebContext*` with a per-user `WebKitWebsiteDataManager` (cookies,
|
||||
cache, local_storage, indexed_db, websql, offline_app_cache, service_worker
|
||||
registrations, itp, hsts all pointed at the per-user webkit dir), favicon
|
||||
DB enabled, TLS errors ignored, security manager configured
|
||||
(sovereign/tor/file secure+local).
|
||||
- `web_context_teardown(WebKitWebContext *ctx)` — unrefs context + data
|
||||
manager, frees per-user state.
|
||||
- Holds the current context pointer (`g_web_ctx`) so the rest of the codebase
|
||||
can fetch it without `webkit_web_context_get_default()`.
|
||||
|
||||
### 3. Refactor scheme/bridge/service init to be re-runnable
|
||||
Currently these are one-shot inits tied to the default context. Make them
|
||||
accept a `WebKitWebContext*` and be safe to call again on a new context:
|
||||
- [`nostr_bridge_register`](src/nostr_bridge.c) — already takes `ctx`; ensure
|
||||
it can be called twice (track prior registration, disconnect old handlers).
|
||||
- [`nostr_scheme_register`](src/nostr_scheme.c) — make it take `ctx` (currently
|
||||
uses default) and idempotent.
|
||||
- [`tor_scheme_register`](src/tor_scheme.c) — same.
|
||||
- [`net_services_init`](src/net_services.c) — currently calls
|
||||
`webkit_web_context_get_default()` internally ([`net_services.c:69`](src/net_services.c));
|
||||
pass `ctx` in instead.
|
||||
|
||||
### 4. Refactor `tab_manager` to support context swap
|
||||
[`tab_manager_init`](src/tab_manager.h:46) currently stores the context once.
|
||||
Add:
|
||||
- `tab_manager_set_context(WebKitWebContext *ctx)` — updates the stored
|
||||
context so subsequent `tab_manager_new_tab` calls use the new context.
|
||||
Existing tabs are already closed (Part B step 1) before this is called.
|
||||
- Or simpler: tear down and re-init `tab_manager` on each switch. Pick whichever
|
||||
is less invasive given the static globals in [`tab_manager.c`](src/tab_manager.c).
|
||||
|
||||
### 5. Wire the login/logout/switch flows
|
||||
- [`app_menu_logout_proxy`](src/main.c:186): after clearing the signer, run the
|
||||
teardown sequence (close all tabs, destroy sidebar, destroy context) then
|
||||
re-show the login dialog. On successful login, run the build sequence.
|
||||
- [`agent_logout`](src/agent_login.c:485): same teardown, then leave the
|
||||
browser in a logged-out state (no context, or a minimal ephemeral context
|
||||
showing a "logged out" page).
|
||||
- [`agent_switch_identity`](src/agent_login.c:492): teardown old, build new
|
||||
with the new pubkey, restore session.
|
||||
- Initial startup in [`main.c`](src/main.c:974): replace
|
||||
`webkit_web_context_get_default()` with `web_context_build_for_pubkey()`
|
||||
after login completes (this means deferring context creation until after
|
||||
the login dialog — reordering the startup sequence).
|
||||
|
||||
### 6. Favicon database per user
|
||||
[`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003)
|
||||
currently uses the default shared location. In `web_context_build_for_pubkey`,
|
||||
pass the per-user webkit dir (or a `favicons/` subdir) so favicons don't leak
|
||||
between users.
|
||||
|
||||
### 7. Read-only / no-login mode
|
||||
When running with `--no-login` or read-only, there is no pubkey to key the
|
||||
data dir on. Use an ephemeral data manager
|
||||
(`webkit_website_data_manager_new_ephemeral()`) so no data persists at all,
|
||||
or a shared `~/.sovereign_browser/webkit-anon/` dir. Decide and document.
|
||||
|
||||
### 8. Migration
|
||||
Existing users have data in WebKit's default location
|
||||
(`~/.cache/sovereign_browser/` or similar). On first login with the new
|
||||
system, optionally copy the default WebKit data dir into
|
||||
`profiles/<pubkey>/webkit/` so they keep their cookies/cache. Low priority —
|
||||
can ship without migration and just start fresh per user.
|
||||
|
||||
### 9. Tests
|
||||
- Manual: log in as user A, visit a site that sets a cookie/localStorage,
|
||||
log out, log in as user B, visit the same site, confirm user A's data is
|
||||
gone and user B gets a fresh session.
|
||||
- Add a test page under [`tests/local-site/`](tests/local-site) that writes
|
||||
a visible marker to localStorage + a cookie, so this is reproducible.
|
||||
- Verify `sovereign://`, `nostr://`, `tor://` schemes still work after a
|
||||
switch (they are re-registered on the new context).
|
||||
- Verify the agent chat sidebar works after a switch (its webview is rebuilt
|
||||
on the new context).
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- New: `src/web_context.c`, `src/web_context.h`
|
||||
- [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) — webkit dir helpers
|
||||
- [`src/main.c`](src/main.c) — startup ordering, logout proxy, use `web_context_*`
|
||||
- [`src/agent_login.c`](src/agent_login.c) — `agent_logout`, `agent_switch_identity`
|
||||
- [`src/tab_manager.c`](src/tab_manager.c) / [`src/tab_manager.h`](src/tab_manager.h) — context swap support, sidebar teardown
|
||||
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — re-runnable registration
|
||||
- [`src/nostr_scheme.c`](src/nostr_scheme.c) — accept ctx, idempotent
|
||||
- [`src/tor_scheme.c`](src/tor_scheme.c) — accept ctx, idempotent
|
||||
- [`src/net_services.c`](src/net_services.c) — accept ctx instead of default
|
||||
- [`Makefile`](Makefile) — add `web_context.o`
|
||||
- New test page: `tests/local-site/identity-leak-test.html`
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **High risk** — changes how the `WebKitWebContext` is created and tears down,
|
||||
which touches every subsystem that references the context.
|
||||
- **Startup reordering** — context creation must move to *after* login, which
|
||||
changes the long-standing flow in [`main.c`](src/main.c).
|
||||
- **Re-runnable scheme registration** — WebKit may not support unregistering
|
||||
scheme handlers cleanly; verify that building a fresh context and
|
||||
re-registering works without leaking the old context.
|
||||
- **Sidebar webview** — the agent chat sidebar ([`tab_manager_toggle_sidebar`](src/tab_manager.h:198))
|
||||
keeps a long-lived webview; must be destroyed on context swap or it will
|
||||
hold a ref to the dead context.
|
||||
- **Mitigation**: implement Part B (clear-on-switch) first as a standalone
|
||||
change — it alone fixes the leak even without per-user dirs. Ship that,
|
||||
then layer Part A (per-user data managers) on top.
|
||||
49
src/agent_chat.c
Normal file
49
src/agent_chat.c
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* agent_chat.c — high-level entry point for the embedded agent chat
|
||||
*
|
||||
* Bridges the URL bar to the agent loop and the chat UI page.
|
||||
*/
|
||||
|
||||
#include "agent_chat.h"
|
||||
#include "agent_loop.h"
|
||||
#include "agent_chat_store.h"
|
||||
#include "tab_manager.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#define AGENT_CHAT_URL "sovereign://agents/chat"
|
||||
|
||||
int agent_chat_route_input(const char *message) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab == NULL || tab->webview == NULL) {
|
||||
g_printerr("[agent_chat] no active tab\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Sidebar-aware behavior: if the agent sidebar is already visible,
|
||||
* just send the message (don't navigate away from the current page).
|
||||
* If the sidebar is not visible, toggle it on first — this opens
|
||||
* the chat in the sidebar alongside the current web page, then
|
||||
* sends the message. This lets the user chat about the page they're
|
||||
* viewing without navigating away from it. */
|
||||
if (!tab_manager_sidebar_visible()) {
|
||||
tab_manager_toggle_sidebar();
|
||||
}
|
||||
|
||||
/* If a non-empty message was provided, start the agent loop. The
|
||||
* sidebar webview polls sovereign://agents/messages and will pick
|
||||
* up the agent's responses automatically. */
|
||||
if (message != NULL && message[0] != '\0') {
|
||||
if (agent_loop_run(message) != 0) {
|
||||
g_printerr("[agent_chat] agent_loop_run failed\n");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *agent_chat_session_id(void) {
|
||||
return agent_chat_store_session_id();
|
||||
}
|
||||
47
src/agent_chat.h
Normal file
47
src/agent_chat.h
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* agent_chat.h — high-level entry point for the embedded agent chat
|
||||
*
|
||||
* Bridges the URL bar to the agent loop and the chat UI page. When the
|
||||
* user types "; <message>" in the URL bar, tab_manager calls
|
||||
* agent_chat_route_input(), which navigates the active tab to
|
||||
* sovereign://agents/chat and kicks off the agent loop.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_CHAT_H
|
||||
#define AGENT_CHAT_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Route a user message from the URL bar to the embedded agent.
|
||||
* Called when the user types "; <message>" in the URL bar.
|
||||
*
|
||||
* Sidebar-aware behavior:
|
||||
* 1. If the agent sidebar is not visible, toggle it on (this opens the
|
||||
* chat page in a narrow sidebar alongside the current web page).
|
||||
* 2. If message is non-NULL and non-empty, starts the agent loop with
|
||||
* that message. The sidebar webview polls for messages and shows
|
||||
* the agent's responses.
|
||||
* 3. If message is NULL or empty, just opens the sidebar (no message
|
||||
* sent).
|
||||
*
|
||||
* This lets the user chat about the page they're viewing without
|
||||
* navigating away from it.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_chat_route_input(const char *message);
|
||||
|
||||
/*
|
||||
* Get the current session ID (for the chat UI to use).
|
||||
* Returns NULL if no session exists. The string is owned by the chat store.
|
||||
*/
|
||||
const char *agent_chat_session_id(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_CHAT_H */
|
||||
177
src/agent_chat_store.c
Normal file
177
src/agent_chat_store.c
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* agent_chat_store.c — thin wrapper over db.c for agent chat sessions
|
||||
*
|
||||
* Manages the "current session" concept and provides convenience
|
||||
* functions for adding messages and loading history in OpenAI format.
|
||||
*/
|
||||
|
||||
#include "agent_chat_store.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Internal state ────────────────────────────────────────────────── */
|
||||
|
||||
/* Owned by the store; freed on replacement or shutdown. */
|
||||
static char *g_current_session_id = NULL;
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Replace g_current_session_id with a newly-allocated copy of `id`.
|
||||
* Frees the previous value. Takes ownership of nothing (copies `id`).
|
||||
* Returns a pointer to the stored string (owned by the store).
|
||||
*/
|
||||
static const char *store_session_id(const char *id) {
|
||||
if (id == NULL) return NULL;
|
||||
if (g_current_session_id) {
|
||||
g_free(g_current_session_id);
|
||||
}
|
||||
g_current_session_id = g_strdup(id);
|
||||
return g_current_session_id;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
const char *agent_chat_store_get_session(void) {
|
||||
if (g_current_session_id) {
|
||||
return g_current_session_id;
|
||||
}
|
||||
|
||||
/* Try to load the latest existing session. */
|
||||
char *latest = db_agent_session_get_latest();
|
||||
if (latest) {
|
||||
const char *stored = store_session_id(latest);
|
||||
g_free(latest);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* No sessions exist — create a new one. */
|
||||
char *id = db_agent_session_create(NULL);
|
||||
if (id == NULL) {
|
||||
g_printerr("[agent_chat_store] failed to create new session\n");
|
||||
return NULL;
|
||||
}
|
||||
const char *stored = store_session_id(id);
|
||||
g_free(id);
|
||||
return stored;
|
||||
}
|
||||
|
||||
const char *agent_chat_store_new_session(const char *title) {
|
||||
char *id = db_agent_session_create(title);
|
||||
if (id == NULL) {
|
||||
g_printerr("[agent_chat_store] failed to create new session\n");
|
||||
return NULL;
|
||||
}
|
||||
const char *stored = store_session_id(id);
|
||||
g_free(id);
|
||||
return stored;
|
||||
}
|
||||
|
||||
const char *agent_chat_store_set_session(const char *session_id) {
|
||||
if (session_id == NULL) return NULL;
|
||||
return store_session_id(session_id);
|
||||
}
|
||||
|
||||
int agent_chat_store_add_user_message(const char *content) {
|
||||
const char *sid = agent_chat_store_get_session();
|
||||
if (sid == NULL) return -1;
|
||||
if (content == NULL) return -1;
|
||||
|
||||
int row = db_agent_message_add(sid, "user", content, NULL, NULL);
|
||||
if (row < 0) {
|
||||
g_printerr("[agent_chat_store] failed to add user message\n");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int agent_chat_store_add_assistant_message(const char *content,
|
||||
const char *tool_calls_json) {
|
||||
const char *sid = agent_chat_store_get_session();
|
||||
if (sid == NULL) return -1;
|
||||
|
||||
int row = db_agent_message_add(sid, "assistant", content,
|
||||
tool_calls_json, NULL);
|
||||
if (row < 0) {
|
||||
g_printerr("[agent_chat_store] failed to add assistant message\n");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int agent_chat_store_add_tool_result(const char *tool_call_id,
|
||||
const char *result_json) {
|
||||
const char *sid = agent_chat_store_get_session();
|
||||
if (sid == NULL) return -1;
|
||||
if (tool_call_id == NULL || result_json == NULL) return -1;
|
||||
|
||||
int row = db_agent_message_add(sid, "tool", result_json, NULL,
|
||||
tool_call_id);
|
||||
if (row < 0) {
|
||||
g_printerr("[agent_chat_store] failed to add tool result\n");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *agent_chat_store_get_messages(void) {
|
||||
const char *sid = agent_chat_store_get_session();
|
||||
if (sid == NULL) return NULL;
|
||||
|
||||
cJSON *raw = db_agent_message_list(sid);
|
||||
if (raw == NULL) {
|
||||
g_printerr("[agent_chat_store] failed to load messages\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Convert to OpenAI-format array. */
|
||||
cJSON *out = cJSON_CreateArray();
|
||||
cJSON *msg;
|
||||
cJSON_ArrayForEach(msg, raw) {
|
||||
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
const char *role = (cJSON_IsString(role_item))
|
||||
? role_item->valuestring : "";
|
||||
|
||||
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
cJSON *tool_calls_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
|
||||
cJSON *tool_call_id_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
|
||||
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(obj, "role", role);
|
||||
|
||||
/* content: include only if non-null. */
|
||||
if (cJSON_IsString(content_item)) {
|
||||
cJSON_AddStringToObject(obj, "content", content_item->valuestring);
|
||||
}
|
||||
|
||||
/* tool_calls: parse the stored JSON string into an array object. */
|
||||
if (cJSON_IsString(tool_calls_item) &&
|
||||
tool_calls_item->valuestring[0] != '\0') {
|
||||
cJSON *parsed = cJSON_Parse(tool_calls_item->valuestring);
|
||||
if (parsed) {
|
||||
cJSON_AddItemToObject(obj, "tool_calls", parsed);
|
||||
} else {
|
||||
/* Store the raw string if it isn't valid JSON. */
|
||||
cJSON_AddStringToObject(obj, "tool_calls",
|
||||
tool_calls_item->valuestring);
|
||||
}
|
||||
}
|
||||
|
||||
/* tool_call_id: for role="tool". */
|
||||
if (cJSON_IsString(tool_call_id_item)) {
|
||||
cJSON_AddStringToObject(obj, "tool_call_id",
|
||||
tool_call_id_item->valuestring);
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(out, obj);
|
||||
}
|
||||
|
||||
cJSON_Delete(raw);
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *agent_chat_store_session_id(void) {
|
||||
return g_current_session_id;
|
||||
}
|
||||
89
src/agent_chat_store.h
Normal file
89
src/agent_chat_store.h
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* agent_chat_store.h — thin wrapper over db.c for agent chat sessions
|
||||
*
|
||||
* Manages the "current session" concept and provides convenience
|
||||
* functions for adding messages and loading history in OpenAI format.
|
||||
*
|
||||
* The store keeps a static g_current_session_id internally. When
|
||||
* agent_chat_store_get_session() is called and no current session is
|
||||
* set, it loads the latest session from the database (or creates a new
|
||||
* one if none exists).
|
||||
*
|
||||
* Thread safety: the underlying database uses SQLITE_OPEN_FULLMUTEX, so
|
||||
* these functions can be called from the background agent thread.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_CHAT_STORE_H
|
||||
#define AGENT_CHAT_STORE_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* cJSON is in the vendored nostr_core_lib */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Get or create the current chat session. If no session exists, creates
|
||||
* a new one. Returns the session ID (owned by the store, do not free).
|
||||
* Returns NULL on error.
|
||||
*/
|
||||
const char *agent_chat_store_get_session(void);
|
||||
|
||||
/*
|
||||
* Start a new chat session. Returns the session ID (owned by the store).
|
||||
* Returns NULL on error.
|
||||
*/
|
||||
const char *agent_chat_store_new_session(const char *title);
|
||||
|
||||
/*
|
||||
* Set the current session to an existing session id. The session must
|
||||
* already exist in the database (e.g. created by db_agent_session_create).
|
||||
* Returns a pointer to the stored session id (owned by the store), or
|
||||
* NULL on error.
|
||||
*/
|
||||
const char *agent_chat_store_set_session(const char *session_id);
|
||||
|
||||
/*
|
||||
* Add a user message to the current session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_chat_store_add_user_message(const char *content);
|
||||
|
||||
/*
|
||||
* Add an assistant message (with optional tool_calls) to the current session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_chat_store_add_assistant_message(const char *content,
|
||||
const char *tool_calls_json);
|
||||
|
||||
/*
|
||||
* Add a tool result message to the current session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_chat_store_add_tool_result(const char *tool_call_id,
|
||||
const char *result_json);
|
||||
|
||||
/*
|
||||
* Load the full message history for the current session as a cJSON array
|
||||
* (suitable for sending to the LLM API). Each message is in OpenAI format:
|
||||
* {"role":"user","content":"..."}
|
||||
* {"role":"assistant","content":"...","tool_calls":[...]}
|
||||
* {"role":"tool","tool_call_id":"...","content":"..."}
|
||||
* Returns NULL on error. Caller must cJSON_Delete().
|
||||
*/
|
||||
cJSON *agent_chat_store_get_messages(void);
|
||||
|
||||
/*
|
||||
* Get the session ID for the current session (or NULL if none).
|
||||
* The returned string is owned by the store.
|
||||
*/
|
||||
const char *agent_chat_store_session_id(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_CHAT_STORE_H */
|
||||
799
src/agent_conversations.c
Normal file
799
src/agent_conversations.c
Normal file
@@ -0,0 +1,799 @@
|
||||
/*
|
||||
* agent_conversations.c — Nostr conversation persistence for agent chat
|
||||
*
|
||||
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
|
||||
* events. Each conversation is stored with:
|
||||
* d-tag: "{uuid}" (no prefix — matches ai.html for cross-app sharing)
|
||||
* t-tag: "client-ai-chat-v1"
|
||||
*
|
||||
* The event content is NIP-44 encrypted (self-to-self) JSON:
|
||||
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
|
||||
*
|
||||
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
|
||||
* from settings_sync.c and bookmarks.c.
|
||||
*
|
||||
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
|
||||
*/
|
||||
|
||||
#include "agent_conversations.h"
|
||||
#include "agent_chat_store.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Global state ───────────────────────────────────────────────────── */
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static int g_have_signer = 0;
|
||||
|
||||
/* ── Init ───────────────────────────────────────────────────────────── */
|
||||
|
||||
void agent_conversations_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void agent_conversations_set_signer(nostr_signer_t *signer,
|
||||
const char *pubkey_hex) {
|
||||
agent_conversations_init(signer, pubkey_hex);
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse bootstrap relays from settings into an array.
|
||||
* Returns the count. Fills urls_out (pointers into relay_buf).
|
||||
* Caller must g_free(relay_buf) after use. (Same pattern as
|
||||
* settings_sync.c and bookmarks.c.) */
|
||||
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
*relay_buf = g_strdup(s->bootstrap_relays);
|
||||
int count = 0;
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && count < max) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
urls_out[count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Encrypt a plaintext string with NIP-44 (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *encrypt_content(const char *plaintext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
|
||||
char *ciphertext = NULL;
|
||||
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
|
||||
&ciphertext);
|
||||
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
|
||||
g_printerr("[agent_conv] NIP-44 encrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
/* Decrypt a NIP-44 ciphertext (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *decrypt_content(const char *ciphertext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
|
||||
|
||||
char *plaintext = NULL;
|
||||
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
|
||||
&plaintext);
|
||||
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
|
||||
g_printerr("[agent_conv] NIP-44 decrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/* Check whether an event has a tag [name, value]. Returns 1/0. */
|
||||
static int event_has_tag(const cJSON *event, const char *name,
|
||||
const char *value) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
|
||||
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Get the value of the first tag with the given name. Returns a pointer
|
||||
* into the event's tags (or NULL). Not owned by caller. */
|
||||
static const char *event_tag_value(const cJSON *event, const char *name) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return NULL;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
|
||||
cJSON_IsString(t1)) {
|
||||
return t1->valuestring;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the full d-tag for a conversation id. With the empty prefix
|
||||
* (AGENT_CONV_D_PREFIX ""), this is just a copy of the conversation id,
|
||||
* matching ai.html's d:{conversation-id} format. Caller must g_free. */
|
||||
static char *build_d_tag(const char *conversation_id) {
|
||||
return g_strdup_printf("%s%s", AGENT_CONV_D_PREFIX, conversation_id);
|
||||
}
|
||||
|
||||
/* Extract the conversation id from a d-tag value. With the empty prefix
|
||||
* the id is the entire d-tag value. Returns NULL only if d_value is NULL. */
|
||||
static const char *d_tag_to_id(const char *d_value) {
|
||||
size_t plen = strlen(AGENT_CONV_D_PREFIX);
|
||||
if (d_value == NULL) return NULL;
|
||||
if (strncmp(d_value, AGENT_CONV_D_PREFIX, plen) != 0) return NULL;
|
||||
return d_value + plen;
|
||||
}
|
||||
|
||||
/* Derive a conversation title from the first user message in a message
|
||||
* array. Returns a newly allocated string (caller must g_free), or
|
||||
* g_strdup("New Chat") if no user message is found. */
|
||||
static char *derive_title(const cJSON *messages) {
|
||||
if (!cJSON_IsArray(messages)) return g_strdup("New Chat");
|
||||
cJSON *msg;
|
||||
cJSON_ArrayForEach(msg, messages) {
|
||||
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
if (cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0) {
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
if (cJSON_IsString(content) && content->valuestring[0]) {
|
||||
/* Truncate to 60 chars, replace newlines with spaces. */
|
||||
char buf[64];
|
||||
size_t i = 0;
|
||||
const char *s = content->valuestring;
|
||||
while (s[i] && i < 60) {
|
||||
buf[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i];
|
||||
i++;
|
||||
}
|
||||
buf[i] = '\0';
|
||||
/* Trim trailing spaces. */
|
||||
while (i > 0 && buf[i - 1] == ' ') buf[--i] = '\0';
|
||||
if (i == 0) return g_strdup("New Chat");
|
||||
return g_strdup(buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
return g_strdup("New Chat");
|
||||
}
|
||||
|
||||
/* ── List ───────────────────────────────────────────────────────────── */
|
||||
|
||||
cJSON *agent_conversations_list(void) {
|
||||
cJSON *result = cJSON_CreateArray();
|
||||
if (g_pubkey[0] == '\0') return result;
|
||||
|
||||
/* Fetch all kind 30078 events for the user. We then filter by the
|
||||
* t-tag. A generous limit covers all conversations. */
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
|
||||
if (events == NULL) return result;
|
||||
|
||||
/* Deduplicate by d-tag (conversation id), keeping only the latest
|
||||
* event (highest created_at) for each conversation. Without this,
|
||||
* re-saving or renaming a conversation leaves the old event in the
|
||||
* SQLite cache, producing duplicate list entries with the same id —
|
||||
* which causes two items to get the "active" highlight in the UI. */
|
||||
GHashTable *latest = g_hash_table_new_full(g_str_hash, g_str_equal,
|
||||
g_free, (GDestroyNotify)cJSON_Delete);
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
|
||||
/* Must have the conversation t-tag. */
|
||||
if (!event_has_tag(ev, "t", AGENT_CONV_T_TAG)) continue;
|
||||
|
||||
/* Extract the conversation id from the d-tag. */
|
||||
const char *d_val = event_tag_value(ev, "d");
|
||||
const char *conv_id = d_tag_to_id(d_val);
|
||||
if (conv_id == NULL) continue;
|
||||
|
||||
/* Extract created_at. */
|
||||
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
long updated = 0;
|
||||
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
|
||||
|
||||
/* Check if we already have a newer event for this conversation. */
|
||||
gpointer prev = g_hash_table_lookup(latest, conv_id);
|
||||
if (prev != NULL) {
|
||||
cJSON *prev_created = cJSON_GetObjectItemCaseSensitive(
|
||||
(cJSON *)prev, "created_at");
|
||||
long prev_updated = 0;
|
||||
if (cJSON_IsNumber(prev_created))
|
||||
prev_updated = (long)prev_created->valuedouble;
|
||||
if (prev_updated >= updated) continue; /* keep the newer one */
|
||||
}
|
||||
|
||||
/* Store a duplicate of this event as the latest for this id. */
|
||||
g_hash_table_replace(latest, g_strdup(conv_id),
|
||||
cJSON_Duplicate(ev, 1));
|
||||
}
|
||||
|
||||
/* Build the result array from the deduplicated hash table. */
|
||||
GHashTableIter iter;
|
||||
gpointer key, value;
|
||||
g_hash_table_iter_init(&iter, latest);
|
||||
while (g_hash_table_iter_next(&iter, &key, &value)) {
|
||||
const char *conv_id = (const char *)key;
|
||||
cJSON *ev = (cJSON *)value;
|
||||
|
||||
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
long updated = 0;
|
||||
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
|
||||
|
||||
/* Try to decrypt the content to get the title. If decryption
|
||||
* fails (e.g. no signer in read-only mode), fall back to the
|
||||
* conversation id. */
|
||||
char *title = NULL;
|
||||
char *plaintext = decrypt_content(
|
||||
cJSON_GetObjectItemCaseSensitive(ev, "content") &&
|
||||
cJSON_IsString(cJSON_GetObjectItemCaseSensitive(ev, "content"))
|
||||
? cJSON_GetObjectItemCaseSensitive(ev, "content")->valuestring
|
||||
: "");
|
||||
if (plaintext) {
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (payload) {
|
||||
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
|
||||
if (cJSON_IsString(t) && t->valuestring[0]) {
|
||||
title = g_strdup(t->valuestring);
|
||||
}
|
||||
cJSON_Delete(payload);
|
||||
}
|
||||
}
|
||||
if (title == NULL) title = g_strdup(conv_id);
|
||||
|
||||
cJSON *summary = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(summary, "id", conv_id);
|
||||
cJSON_AddStringToObject(summary, "title", title);
|
||||
cJSON_AddNumberToObject(summary, "updated_at", (double)updated);
|
||||
cJSON_AddItemToArray(result, summary);
|
||||
|
||||
g_free(title);
|
||||
}
|
||||
|
||||
g_hash_table_destroy(latest);
|
||||
cJSON_Delete(events);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Save ───────────────────────────────────────────────────────────── */
|
||||
|
||||
char *agent_conversations_save(const char *conversation_id,
|
||||
const char *title) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') {
|
||||
g_printerr("[agent_conv] No signer, cannot save\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Determine the conversation id. Generate one if not provided. */
|
||||
char *conv_id = NULL;
|
||||
if (conversation_id && conversation_id[0]) {
|
||||
conv_id = g_strdup(conversation_id);
|
||||
} else {
|
||||
conv_id = g_uuid_string_random();
|
||||
if (conv_id == NULL) {
|
||||
g_printerr("[agent_conv] Failed to generate conversation id\n");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fetch the current session's messages. */
|
||||
cJSON *messages = agent_chat_store_get_messages();
|
||||
if (messages == NULL) {
|
||||
g_printerr("[agent_conv] Failed to get messages for save\n");
|
||||
g_free(conv_id);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Determine the title:
|
||||
* - If a title is provided, use it.
|
||||
* - If no title but the conversation already exists on Nostr,
|
||||
* preserve the existing title (don't overwrite a renamed title).
|
||||
* - If no title and no existing conversation, derive from the
|
||||
* first user message. */
|
||||
char *use_title = NULL;
|
||||
if (title && title[0]) {
|
||||
use_title = g_strdup(title);
|
||||
} else {
|
||||
/* Try to fetch the existing event's title. */
|
||||
char *existing_title = NULL;
|
||||
char *d_tag_val = build_d_tag(conv_id);
|
||||
if (d_tag_val && g_pubkey[0]) {
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
|
||||
if (events) {
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag_val)) {
|
||||
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(ev, "content");
|
||||
if (cJSON_IsString(content_item)) {
|
||||
char *plaintext = decrypt_content(content_item->valuestring);
|
||||
if (plaintext) {
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
if (payload) {
|
||||
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
|
||||
if (cJSON_IsString(t) && t->valuestring[0]) {
|
||||
existing_title = g_strdup(t->valuestring);
|
||||
}
|
||||
cJSON_Delete(payload);
|
||||
}
|
||||
free(plaintext);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
}
|
||||
g_free(d_tag_val);
|
||||
if (existing_title) {
|
||||
use_title = existing_title;
|
||||
} else {
|
||||
use_title = derive_title(messages);
|
||||
}
|
||||
}
|
||||
|
||||
/* Build the payload: { schema: 1, title: "...", messages: [...] }.
|
||||
* Only save user and assistant messages to Nostr — tool messages
|
||||
* (tool call results) can be very large (e.g. entire web pages)
|
||||
* and would bloat the kind 30078 event. They're kept in the local
|
||||
* SQLite session for the agent loop's context but not synced. */
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(payload, "schema", 1);
|
||||
cJSON_AddStringToObject(payload, "title", use_title);
|
||||
cJSON *saved_msgs = cJSON_CreateArray();
|
||||
cJSON *msg;
|
||||
cJSON_ArrayForEach(msg, messages) {
|
||||
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
const char *role_str = cJSON_IsString(role) ? role->valuestring : "";
|
||||
if (strcmp(role_str, "user") == 0 || strcmp(role_str, "assistant") == 0) {
|
||||
cJSON_AddItemToArray(saved_msgs, cJSON_Duplicate(msg, 1));
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(payload, "messages", saved_msgs);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(payload);
|
||||
cJSON_Delete(payload);
|
||||
if (json == NULL) {
|
||||
g_printerr("[agent_conv] Failed to serialize payload\n");
|
||||
cJSON_Delete(messages);
|
||||
g_free(conv_id);
|
||||
g_free(use_title);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Encrypt. */
|
||||
char *ciphertext = encrypt_content(json);
|
||||
free(json);
|
||||
cJSON_Delete(messages);
|
||||
if (ciphertext == NULL) {
|
||||
g_free(conv_id);
|
||||
g_free(use_title);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build tags: [["d", "{id}"],
|
||||
* ["t", "client-ai-chat-v1"],
|
||||
* ["client", "sovereign_browser"]] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
char *d_tag_val = build_d_tag(conv_id);
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
g_free(d_tag_val);
|
||||
|
||||
cJSON *t_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
|
||||
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
|
||||
cJSON_AddItemToArray(tags, t_tag);
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
/* Create and sign the event. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
|
||||
g_free(ciphertext);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[agent_conv] Failed to create/sign event\n");
|
||||
cJSON_Delete(tags);
|
||||
g_free(conv_id);
|
||||
g_free(use_title);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_conv] Saved conversation %s to %d/%d relays\n",
|
||||
conv_id, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[agent_conv] No relays configured, event stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
g_free(use_title);
|
||||
return conv_id;
|
||||
}
|
||||
|
||||
/* ── Load ───────────────────────────────────────────────────────────── */
|
||||
|
||||
int agent_conversations_load(const char *conversation_id) {
|
||||
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
|
||||
if (g_pubkey[0] == '\0') return -1;
|
||||
|
||||
/* Build the d-tag to look for. */
|
||||
char *d_tag_val = build_d_tag(conversation_id);
|
||||
|
||||
/* Fetch all kind 30078 events and find the one with our d-tag.
|
||||
* (db_get_latest_event doesn't filter by d-tag, so we scan.) */
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
|
||||
if (events == NULL) {
|
||||
g_free(d_tag_val);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *found = NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag_val)) {
|
||||
found = cJSON_Duplicate(ev, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
g_free(d_tag_val);
|
||||
|
||||
if (found == NULL) {
|
||||
g_printerr("[agent_conv] Conversation %s not found in cache\n",
|
||||
conversation_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Decrypt the content. */
|
||||
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
|
||||
if (!cJSON_IsString(content_item)) {
|
||||
cJSON_Delete(found);
|
||||
return -1;
|
||||
}
|
||||
char *plaintext = decrypt_content(content_item->valuestring);
|
||||
cJSON_Delete(found);
|
||||
if (plaintext == NULL) return -1;
|
||||
|
||||
/* Parse the payload. */
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (payload == NULL) {
|
||||
g_printerr("[agent_conv] Failed to parse decrypted payload\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
|
||||
const char *title = (cJSON_IsString(title_item))
|
||||
? title_item->valuestring : "Loaded Chat";
|
||||
|
||||
cJSON *messages = cJSON_GetObjectItemCaseSensitive(payload, "messages");
|
||||
if (!cJSON_IsArray(messages)) {
|
||||
g_printerr("[agent_conv] No messages array in payload\n");
|
||||
cJSON_Delete(payload);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Create a new agent chat session and populate it with the messages. */
|
||||
const char *new_sid = agent_chat_store_new_session(title);
|
||||
if (new_sid == NULL) {
|
||||
cJSON_Delete(payload);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Add each message to the new session. */
|
||||
cJSON *msg;
|
||||
cJSON_ArrayForEach(msg, messages) {
|
||||
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
cJSON *content_m = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
cJSON *tc_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
|
||||
cJSON *tcid_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
|
||||
|
||||
const char *role = (cJSON_IsString(role_item))
|
||||
? role_item->valuestring : "user";
|
||||
const char *content = (cJSON_IsString(content_m))
|
||||
? content_m->valuestring : NULL;
|
||||
|
||||
/* Serialize tool_calls back to a string for storage. */
|
||||
char *tc_str = NULL;
|
||||
if (tc_item && !cJSON_IsNull(tc_item)) {
|
||||
tc_str = cJSON_PrintUnformatted(tc_item);
|
||||
}
|
||||
|
||||
const char *tcid = (cJSON_IsString(tcid_item) &&
|
||||
tcid_item->valuestring[0])
|
||||
? tcid_item->valuestring : NULL;
|
||||
|
||||
db_agent_message_add(new_sid, role, content, tc_str, tcid);
|
||||
if (tc_str) free(tc_str);
|
||||
}
|
||||
|
||||
cJSON_Delete(payload);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Delete ─────────────────────────────────────────────────────────── */
|
||||
|
||||
int agent_conversations_delete(const char *conversation_id) {
|
||||
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
|
||||
if (g_pubkey[0] == '\0') return -1;
|
||||
|
||||
/* Find the event id for this conversation. */
|
||||
char *d_tag_val = build_d_tag(conversation_id);
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
|
||||
if (events == NULL) {
|
||||
g_free(d_tag_val);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char *event_id = NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag_val)) {
|
||||
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
|
||||
if (cJSON_IsString(id_item)) {
|
||||
event_id = g_strdup(id_item->valuestring);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
g_free(d_tag_val);
|
||||
|
||||
if (event_id == NULL) {
|
||||
g_printerr("[agent_conv] Conversation %s not found for delete\n",
|
||||
conversation_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Publish a kind 5 deletion event referencing the conversation event.
|
||||
* The content is an optional reason; tags list the event id to delete. */
|
||||
if (g_have_signer) {
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
cJSON *e_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
|
||||
cJSON_AddItemToArray(tags, e_tag);
|
||||
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
5, "Deleted conversation", tags, g_signer, time(NULL));
|
||||
cJSON_Delete(tags);
|
||||
|
||||
if (event) {
|
||||
db_store_event(event);
|
||||
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results =
|
||||
synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_conv] Published deletion for %s to %d/%d relays\n",
|
||||
conversation_id, success_count, relay_count);
|
||||
}
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove the kind 30078 event from the local SQLite cache so it
|
||||
* no longer appears in conversation lists. The kind 5 tombstone
|
||||
* published above handles relay-side deletion. */
|
||||
db_delete_event(event_id);
|
||||
|
||||
g_free(event_id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Rename ─────────────────────────────────────────────────────────── */
|
||||
|
||||
int agent_conversations_rename(const char *conversation_id,
|
||||
const char *new_title) {
|
||||
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
|
||||
if (new_title == NULL || new_title[0] == '\0') return -1;
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') {
|
||||
g_printerr("[agent_conv] No signer, cannot rename\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Find the existing event for this conversation. */
|
||||
char *d_tag_val = build_d_tag(conversation_id);
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
|
||||
if (events == NULL) {
|
||||
g_free(d_tag_val);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *found = NULL;
|
||||
char *old_event_id = NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag_val)) {
|
||||
found = cJSON_Duplicate(ev, 1);
|
||||
/* Remember the old event's id so we can delete it from
|
||||
* SQLite after storing the renamed event. This prevents
|
||||
* duplicate events with the same d-tag. */
|
||||
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
|
||||
if (cJSON_IsString(id_item)) {
|
||||
old_event_id = g_strdup(id_item->valuestring);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
g_free(d_tag_val);
|
||||
|
||||
if (found == NULL) {
|
||||
g_printerr("[agent_conv] Conversation %s not found for rename\n",
|
||||
conversation_id);
|
||||
g_free(old_event_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Decrypt the content. */
|
||||
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
|
||||
if (!cJSON_IsString(content_item)) {
|
||||
cJSON_Delete(found);
|
||||
return -1;
|
||||
}
|
||||
char *plaintext = decrypt_content(content_item->valuestring);
|
||||
cJSON_Delete(found);
|
||||
if (plaintext == NULL) return -1;
|
||||
|
||||
/* Parse the payload, replace the title, re-serialize. */
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (payload == NULL) {
|
||||
g_printerr("[agent_conv] Failed to parse payload for rename\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Replace the title field. */
|
||||
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
|
||||
if (title_item) {
|
||||
cJSON_ReplaceItemInObjectCaseSensitive(payload, "title",
|
||||
cJSON_CreateString(new_title));
|
||||
} else {
|
||||
cJSON_AddStringToObject(payload, "title", new_title);
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(payload);
|
||||
cJSON_Delete(payload);
|
||||
if (json == NULL) {
|
||||
g_printerr("[agent_conv] Failed to serialize renamed payload\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Re-encrypt. */
|
||||
char *ciphertext = encrypt_content(json);
|
||||
free(json);
|
||||
if (ciphertext == NULL) return -1;
|
||||
|
||||
/* Build tags (same structure as save: d, t, client). */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
char *d_tag_val2 = build_d_tag(conversation_id);
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val2));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
g_free(d_tag_val2);
|
||||
|
||||
cJSON *t_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
|
||||
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
|
||||
cJSON_AddItemToArray(tags, t_tag);
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
/* Create and sign the new event. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
|
||||
g_free(ciphertext);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[agent_conv] Failed to create/sign rename event\n");
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Store the renamed event in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Delete the old event from SQLite so there's only one event per
|
||||
* d-tag. Without this, both the old and new events exist, and the
|
||||
* dedup logic in agent_conversations_list() might pick the wrong
|
||||
* one if the old event has a higher created_at. */
|
||||
if (old_event_id) {
|
||||
db_delete_event(old_event_id);
|
||||
g_free(old_event_id);
|
||||
old_event_id = NULL;
|
||||
}
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_conv] Renamed conversation %s to %d/%d relays\n",
|
||||
conversation_id, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[agent_conv] No relays configured, rename stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
return 0;
|
||||
}
|
||||
144
src/agent_conversations.h
Normal file
144
src/agent_conversations.h
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* agent_conversations.h — Nostr conversation persistence for agent chat
|
||||
*
|
||||
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
|
||||
* events. Each conversation is stored with:
|
||||
* d-tag: "{uuid}" (no prefix — matches ai.html so conversations are
|
||||
* shared between sovereign_browser and the client)
|
||||
* t-tag: "client-ai-chat-v1"
|
||||
*
|
||||
* The event content is NIP-44 encrypted (self-to-self) JSON:
|
||||
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
|
||||
*
|
||||
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
|
||||
* from settings_sync.c and bookmarks.c.
|
||||
*
|
||||
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_CONVERSATIONS_H
|
||||
#define AGENT_CONVERSATIONS_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* cJSON is in the vendored nostr_core_lib */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* nostr_signer_t is needed for init */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The Nostr kind for arbitrary custom app data (NIP-78). */
|
||||
#define AGENT_CONV_KIND 30078
|
||||
|
||||
/* The t-tag that identifies these conversation events. Matches ai.html
|
||||
* so conversations are shared between sovereign_browser and the client. */
|
||||
#define AGENT_CONV_T_TAG "client-ai-chat-v1"
|
||||
|
||||
/* The prefix for the d-tag. Empty — the d-tag is just the UUID, matching
|
||||
* ai.html's d:{conversation-id} format. Kept as a macro for compatibility
|
||||
* with build_d_tag()/d_tag_to_id(). */
|
||||
#define AGENT_CONV_D_PREFIX ""
|
||||
|
||||
/*
|
||||
* Initialize the conversation persistence module. Stores the signer + pubkey
|
||||
* references for save/load/delete operations.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
|
||||
*
|
||||
* Call after login.
|
||||
*/
|
||||
void agent_conversations_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Update the signer reference (e.g. after switching identity).
|
||||
*/
|
||||
void agent_conversations_set_signer(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* List all saved conversations from the local SQLite cache.
|
||||
*
|
||||
* Queries kind 30078 events by the user's pubkey that have the
|
||||
* t-tag "client-ai-chat-v1". Extracts the conversation id
|
||||
* (from the d-tag, which is just the UUID), title (from the
|
||||
* decrypted content), and updated_at (from created_at).
|
||||
*
|
||||
* Returns a newly allocated cJSON array of summary objects:
|
||||
* [{"id":"...", "title":"...", "updated_at":N}, ...]
|
||||
* Returns an empty array on error or if no conversations exist.
|
||||
* Caller must cJSON_Delete() the result.
|
||||
*/
|
||||
cJSON *agent_conversations_list(void);
|
||||
|
||||
/*
|
||||
* Save the current agent chat session to Nostr as a kind 30078 event.
|
||||
*
|
||||
* conversation_id — the conversation id (UUID). If NULL, a new UUID is
|
||||
* generated and the d-tag becomes just "{uuid}"
|
||||
* (no prefix, matching ai.html).
|
||||
* title — the conversation title. If NULL, derived from the
|
||||
* first user message (truncated to 60 chars).
|
||||
*
|
||||
* Fetches the current session's messages via agent_chat_store, NIP-44
|
||||
* encrypts them, builds a kind 30078 event with the d-tag and t-tag,
|
||||
* signs it, publishes to bootstrap relays, and stores in SQLite.
|
||||
*
|
||||
* Returns a newly allocated string with the conversation id (caller must
|
||||
* g_free), or NULL on error.
|
||||
*/
|
||||
char *agent_conversations_save(const char *conversation_id,
|
||||
const char *title);
|
||||
|
||||
/*
|
||||
* Load a conversation from Nostr (local SQLite cache) into the agent chat
|
||||
* store as the current session.
|
||||
*
|
||||
* conversation_id — the conversation id (the UUID used as the d-tag).
|
||||
*
|
||||
* Fetches the kind 30078 event with d-tag "{id}", NIP-44 decrypts
|
||||
* the content, parses the messages, creates a new agent chat session,
|
||||
* and populates it with the parsed messages.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_conversations_load(const char *conversation_id);
|
||||
|
||||
/*
|
||||
* Rename a conversation — update its title in the local SQLite cache
|
||||
* and re-publish the kind 30078 event with the new title.
|
||||
*
|
||||
* conversation_id — the conversation id (the UUID used as the d-tag).
|
||||
* new_title — the new title (must be non-NULL and non-empty).
|
||||
*
|
||||
* Loads the existing event, decrypts the content, replaces the title,
|
||||
* re-encrypts, signs a new kind 30078 event with the same d-tag, stores
|
||||
* it in SQLite, and publishes to bootstrap relays.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_conversations_rename(const char *conversation_id,
|
||||
const char *new_title);
|
||||
|
||||
/*
|
||||
* Delete a conversation from Nostr.
|
||||
*
|
||||
* conversation_id — the conversation id (the UUID used as the d-tag).
|
||||
*
|
||||
* Publishes a kind 5 deletion event referencing the kind 30078 event id,
|
||||
* and removes the event from the local SQLite cache.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_conversations_delete(const char *conversation_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_CONVERSATIONS_H */
|
||||
488
src/agent_fs_tools.c
Normal file
488
src/agent_fs_tools.c
Normal file
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
* agent_fs_tools.c — filesystem & shell tool implementations
|
||||
*
|
||||
* Tools:
|
||||
* fs_read — read a file's text contents
|
||||
* fs_write — write text to a file (overwrite or create)
|
||||
* fs_list — list directory entries
|
||||
* fs_mkdir — create a directory recursively
|
||||
* fs_delete — delete a file or empty directory
|
||||
* shell_exec — run a shell command, return stdout+stderr+exit code
|
||||
*
|
||||
* The browser runs in a dedicated Qubes OS qube, so full filesystem
|
||||
* and shell access is intended. These tools work before login.
|
||||
*/
|
||||
|
||||
#include "agent_fs_tools.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* ── JSON helpers (same pattern as agent_tools.c) ──────────────────── */
|
||||
|
||||
static cJSON *make_success(cJSON *data) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", TRUE);
|
||||
if (data) {
|
||||
cJSON_AddItemToObject(resp, "data", data);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
static cJSON *make_error(const char *code, const char *message) {
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(resp, "success", FALSE);
|
||||
cJSON *err = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(err, "code", code);
|
||||
cJSON_AddStringToObject(err, "message", message);
|
||||
cJSON_AddItemToObject(resp, "error", err);
|
||||
return resp;
|
||||
}
|
||||
|
||||
static const char *get_string_param(cJSON *params, const char *key) {
|
||||
return cJSON_GetStringValue(cJSON_GetObjectItem(params, key));
|
||||
}
|
||||
|
||||
static int get_int_param(cJSON *params, const char *key, int fallback) {
|
||||
cJSON *item = cJSON_GetObjectItem(params, key);
|
||||
if (item && cJSON_IsNumber(item)) return item->valueint;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ── fs_read ───────────────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *tool_fs_read(cJSON *params) {
|
||||
const char *path = get_string_param(params, "path");
|
||||
if (!path || !path[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'path'");
|
||||
}
|
||||
|
||||
/* Stat the file first to get the size and verify it's a regular file. */
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) {
|
||||
return make_error("FILE_NOT_FOUND",
|
||||
g_strdup_printf("Cannot stat '%s': %s", path, strerror(errno)));
|
||||
}
|
||||
if (!S_ISREG(st.st_mode)) {
|
||||
return make_error("NOT_A_FILE",
|
||||
g_strdup_printf("'%s' is not a regular file", path));
|
||||
}
|
||||
|
||||
/* Cap reads at 16 MB to avoid exhausting memory on huge files. */
|
||||
off_t size = st.st_size;
|
||||
if (size > (16 * 1024 * 1024)) {
|
||||
return make_error("FILE_TOO_LARGE",
|
||||
g_strdup_printf("'%s' is %lld bytes; max read is 16 MB",
|
||||
path, (long long)size));
|
||||
}
|
||||
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
return make_error("OPEN_FAILED",
|
||||
g_strdup_printf("Cannot open '%s': %s", path, strerror(errno)));
|
||||
}
|
||||
|
||||
char *buf = g_malloc(size + 1);
|
||||
if (buf == NULL) {
|
||||
close(fd);
|
||||
return make_error("OUT_OF_MEMORY", "Cannot allocate read buffer");
|
||||
}
|
||||
|
||||
off_t total = 0;
|
||||
while (total < size) {
|
||||
ssize_t n = read(fd, buf + total, size - total);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
g_free(buf);
|
||||
close(fd);
|
||||
return make_error("READ_FAILED",
|
||||
g_strdup_printf("Read error on '%s': %s", path, strerror(errno)));
|
||||
}
|
||||
if (n == 0) break; /* EOF (file shrank?) */
|
||||
total += n;
|
||||
}
|
||||
buf[total] = '\0';
|
||||
close(fd);
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "content", buf);
|
||||
g_free(buf);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── fs_write ──────────────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *tool_fs_write(cJSON *params) {
|
||||
const char *path = get_string_param(params, "path");
|
||||
if (!path || !path[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'path'");
|
||||
}
|
||||
const char *content = get_string_param(params, "content");
|
||||
if (content == NULL) {
|
||||
/* Allow NULL to be treated as empty string. */
|
||||
content = "";
|
||||
}
|
||||
|
||||
/* Create parent directories if needed so the write succeeds. */
|
||||
char *parent = g_path_get_dirname(path);
|
||||
if (parent && parent[0] && strcmp(parent, ".") != 0) {
|
||||
g_mkdir_with_parents(parent, 0755);
|
||||
}
|
||||
g_free(parent);
|
||||
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
return make_error("OPEN_FAILED",
|
||||
g_strdup_printf("Cannot open '%s' for writing: %s",
|
||||
path, strerror(errno)));
|
||||
}
|
||||
|
||||
size_t len = strlen(content);
|
||||
size_t total = 0;
|
||||
while (total < len) {
|
||||
ssize_t n = write(fd, content + total, len - total);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
close(fd);
|
||||
return make_error("WRITE_FAILED",
|
||||
g_strdup_printf("Write error on '%s': %s", path, strerror(errno)));
|
||||
}
|
||||
total += (size_t)n;
|
||||
}
|
||||
close(fd);
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(data, "bytes_written", (double)total);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── fs_list ───────────────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *tool_fs_list(cJSON *params) {
|
||||
const char *path = get_string_param(params, "path");
|
||||
if (!path || !path[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'path'");
|
||||
}
|
||||
|
||||
DIR *dir = opendir(path);
|
||||
if (dir == NULL) {
|
||||
return make_error("OPEN_FAILED",
|
||||
g_strdup_printf("Cannot open directory '%s': %s",
|
||||
path, strerror(errno)));
|
||||
}
|
||||
|
||||
cJSON *entries = cJSON_CreateArray();
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(dir)) != NULL) {
|
||||
/* Skip "." and ".." — they're not useful for an agent. */
|
||||
if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Build the full path to stat the entry. */
|
||||
char *full = g_strdup_printf("%s/%s", path, ent->d_name);
|
||||
struct stat st;
|
||||
const char *type = "file";
|
||||
long long size = 0;
|
||||
if (stat(full, &st) == 0) {
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
type = "dir";
|
||||
} else if (S_ISLNK(st.st_mode)) {
|
||||
type = "link";
|
||||
} else if (!S_ISREG(st.st_mode)) {
|
||||
type = "other";
|
||||
}
|
||||
size = (long long)st.st_size;
|
||||
}
|
||||
g_free(full);
|
||||
|
||||
cJSON *entry = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(entry, "name", ent->d_name);
|
||||
cJSON_AddStringToObject(entry, "type", type);
|
||||
cJSON_AddNumberToObject(entry, "size", (double)size);
|
||||
cJSON_AddItemToArray(entries, entry);
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(data, "entries", entries);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── fs_mkdir ──────────────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *tool_fs_mkdir(cJSON *params) {
|
||||
const char *path = get_string_param(params, "path");
|
||||
if (!path || !path[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'path'");
|
||||
}
|
||||
|
||||
if (g_mkdir_with_parents(path, 0755) != 0) {
|
||||
return make_error("MKDIR_FAILED",
|
||||
g_strdup_printf("Cannot create directory '%s': %s",
|
||||
path, strerror(errno)));
|
||||
}
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(data, "created", TRUE);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── fs_delete ─────────────────────────────────────────────────────── */
|
||||
|
||||
static cJSON *tool_fs_delete(cJSON *params) {
|
||||
const char *path = get_string_param(params, "path");
|
||||
if (!path || !path[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'path'");
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) {
|
||||
return make_error("FILE_NOT_FOUND",
|
||||
g_strdup_printf("'%s' does not exist: %s", path, strerror(errno)));
|
||||
}
|
||||
|
||||
int rc;
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
rc = rmdir(path);
|
||||
} else {
|
||||
rc = unlink(path);
|
||||
}
|
||||
|
||||
if (rc != 0) {
|
||||
return make_error("DELETE_FAILED",
|
||||
g_strdup_printf("Cannot delete '%s': %s", path, strerror(errno)));
|
||||
}
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(data, "deleted", TRUE);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── shell_exec ────────────────────────────────────────────────────── *
|
||||
*
|
||||
* Runs a shell command via fork() + execvp("/bin/sh", "-c", command),
|
||||
* capturing stdout and stderr through separate pipes. A timeout is
|
||||
* enforced with a waitpid() polling loop; if the deadline is exceeded
|
||||
* the child is killed with SIGKILL and we report exit_code -1.
|
||||
*/
|
||||
|
||||
static cJSON *tool_shell_exec(cJSON *params) {
|
||||
const char *command = get_string_param(params, "command");
|
||||
if (!command || !command[0]) {
|
||||
return make_error("MISSING_PARAM", "Provide 'command'");
|
||||
}
|
||||
int timeout_ms = get_int_param(params, "timeout_ms", 30000);
|
||||
if (timeout_ms < 0) timeout_ms = 30000;
|
||||
|
||||
/* Create pipes for stdout and stderr. */
|
||||
int out_pipe[2];
|
||||
int err_pipe[2];
|
||||
if (pipe(out_pipe) != 0) {
|
||||
return make_error("PIPE_FAILED",
|
||||
g_strdup_printf("pipe() failed: %s", strerror(errno)));
|
||||
}
|
||||
if (pipe(err_pipe) != 0) {
|
||||
close(out_pipe[0]);
|
||||
close(out_pipe[1]);
|
||||
return make_error("PIPE_FAILED",
|
||||
g_strdup_printf("pipe() failed: %s", strerror(errno)));
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(out_pipe[0]); close(out_pipe[1]);
|
||||
close(err_pipe[0]); close(err_pipe[1]);
|
||||
return make_error("FORK_FAILED",
|
||||
g_strdup_printf("fork() failed: %s", strerror(errno)));
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/* ── Child ──────────────────────────────────────────────── */
|
||||
/* Redirect stdout and stderr to the pipes. */
|
||||
dup2(out_pipe[1], STDOUT_FILENO);
|
||||
dup2(err_pipe[1], STDERR_FILENO);
|
||||
close(out_pipe[0]); close(out_pipe[1]);
|
||||
close(err_pipe[0]); close(err_pipe[1]);
|
||||
|
||||
/* Run the command via /bin/sh -c. */
|
||||
char *const argv[] = {"sh", "-c", (char *)command, NULL};
|
||||
execvp("/bin/sh", argv);
|
||||
/* If execvp returns, it failed. */
|
||||
fprintf(stderr, "execvp failed: %s\n", strerror(errno));
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* ── Parent ──────────────────────────────────────────────────── */
|
||||
close(out_pipe[1]);
|
||||
close(err_pipe[1]);
|
||||
|
||||
/* Read stdout and stderr fully (non-blocking with poll). */
|
||||
GString *out_buf = g_string_new(NULL);
|
||||
GString *err_buf = g_string_new(NULL);
|
||||
|
||||
/* Set both pipe read ends to non-blocking. */
|
||||
fcntl(out_pipe[0], F_SETFL, O_NONBLOCK);
|
||||
fcntl(err_pipe[0], F_SETFL, O_NONBLOCK);
|
||||
|
||||
long deadline_ms = (long)(g_get_monotonic_time() / 1000) + (long)timeout_ms;
|
||||
int exit_code = -1;
|
||||
gboolean timed_out = FALSE;
|
||||
gboolean child_done = FALSE;
|
||||
|
||||
char chunk[4096];
|
||||
while (!child_done) {
|
||||
/* Check timeout. */
|
||||
long now_ms = (long)(g_get_monotonic_time() / 1000);
|
||||
if (now_ms >= deadline_ms) {
|
||||
timed_out = TRUE;
|
||||
kill(pid, SIGKILL);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Poll the pipes for up to 50ms, then check the child. */
|
||||
struct pollfd pfds[2];
|
||||
int nfds = 0;
|
||||
pfds[nfds].fd = out_pipe[0];
|
||||
pfds[nfds].events = POLLIN;
|
||||
nfds++;
|
||||
pfds[nfds].fd = err_pipe[0];
|
||||
pfds[nfds].events = POLLIN;
|
||||
nfds++;
|
||||
|
||||
int pr = poll(pfds, (nfds_t)nfds, 50);
|
||||
if (pr > 0) {
|
||||
for (int i = 0; i < nfds; i++) {
|
||||
if (pfds[i].revents & POLLIN) {
|
||||
ssize_t n;
|
||||
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
|
||||
if (pfds[i].fd == out_pipe[0]) {
|
||||
g_string_append_len(out_buf, chunk, n);
|
||||
} else {
|
||||
g_string_append_len(err_buf, chunk, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pfds[i].revents & (POLLHUP | POLLERR)) {
|
||||
/* Drain remaining, then mark closed. */
|
||||
ssize_t n;
|
||||
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
|
||||
if (pfds[i].fd == out_pipe[0]) {
|
||||
g_string_append_len(out_buf, chunk, n);
|
||||
} else {
|
||||
g_string_append_len(err_buf, chunk, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Check if the child has exited. */
|
||||
int status;
|
||||
pid_t wr = waitpid(pid, &status, WNOHANG);
|
||||
if (wr == pid) {
|
||||
child_done = TRUE;
|
||||
if (WIFEXITED(status)) {
|
||||
exit_code = WEXITSTATUS(status);
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
exit_code = 128 + WTERMSIG(status);
|
||||
} else {
|
||||
exit_code = -1;
|
||||
}
|
||||
} else if (wr < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
/* waitpid error — treat as done. */
|
||||
child_done = TRUE;
|
||||
exit_code = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* If we broke out due to timeout, reap the child. */
|
||||
if (timed_out) {
|
||||
int status;
|
||||
/* Give it a moment to die from SIGKILL. */
|
||||
for (int i = 0; i < 10; i++) {
|
||||
pid_t wr = waitpid(pid, &status, WNOHANG);
|
||||
if (wr == pid) break;
|
||||
if (wr < 0 && errno != EINTR) break;
|
||||
struct timespec ts = {0, 10 * 1000 * 1000}; /* 10ms */
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
/* Final blocking reap. */
|
||||
waitpid(pid, &status, 0);
|
||||
exit_code = -1;
|
||||
}
|
||||
|
||||
/* Drain any remaining data from the pipes after the child exited. */
|
||||
ssize_t n;
|
||||
while ((n = read(out_pipe[0], chunk, sizeof(chunk))) > 0) {
|
||||
g_string_append_len(out_buf, chunk, n);
|
||||
}
|
||||
while ((n = read(err_pipe[0], chunk, sizeof(chunk))) > 0) {
|
||||
g_string_append_len(err_buf, chunk, n);
|
||||
}
|
||||
|
||||
close(out_pipe[0]);
|
||||
close(err_pipe[0]);
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "stdout", out_buf->str);
|
||||
cJSON_AddStringToObject(data, "stderr", err_buf->str);
|
||||
cJSON_AddNumberToObject(data, "exit_code", exit_code);
|
||||
if (timed_out) {
|
||||
cJSON_AddBoolToObject(data, "timed_out", TRUE);
|
||||
}
|
||||
g_string_free(out_buf, TRUE);
|
||||
g_string_free(err_buf, TRUE);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* ── Dispatch ──────────────────────────────────────────────────────── */
|
||||
|
||||
int agent_fs_is_tool(const char *tool_name) {
|
||||
if (tool_name == NULL) return 0;
|
||||
return (strcmp(tool_name, "fs_read") == 0 ||
|
||||
strcmp(tool_name, "fs_write") == 0 ||
|
||||
strcmp(tool_name, "fs_list") == 0 ||
|
||||
strcmp(tool_name, "fs_mkdir") == 0 ||
|
||||
strcmp(tool_name, "fs_delete") == 0 ||
|
||||
strcmp(tool_name, "shell_exec") == 0);
|
||||
}
|
||||
|
||||
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params) {
|
||||
if (tool_name == NULL) return NULL;
|
||||
if (params == NULL) params = cJSON_CreateObject();
|
||||
|
||||
if (strcmp(tool_name, "fs_read") == 0) {
|
||||
return tool_fs_read(params);
|
||||
}
|
||||
if (strcmp(tool_name, "fs_write") == 0) {
|
||||
return tool_fs_write(params);
|
||||
}
|
||||
if (strcmp(tool_name, "fs_list") == 0) {
|
||||
return tool_fs_list(params);
|
||||
}
|
||||
if (strcmp(tool_name, "fs_mkdir") == 0) {
|
||||
return tool_fs_mkdir(params);
|
||||
}
|
||||
if (strcmp(tool_name, "fs_delete") == 0) {
|
||||
return tool_fs_delete(params);
|
||||
}
|
||||
if (strcmp(tool_name, "shell_exec") == 0) {
|
||||
return tool_shell_exec(params);
|
||||
}
|
||||
return NULL; /* not an fs/shell tool */
|
||||
}
|
||||
42
src/agent_fs_tools.h
Normal file
42
src/agent_fs_tools.h
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* agent_fs_tools.h — filesystem & shell tools for the embedded agent
|
||||
*
|
||||
* Provides tools that give the agent direct filesystem and shell access
|
||||
* within the browser's qube. These tools work before login (they are
|
||||
* system-level, not browser-level) and are dispatched early in
|
||||
* agent_tools_dispatch().
|
||||
*/
|
||||
|
||||
#ifndef AGENT_FS_TOOLS_H
|
||||
#define AGENT_FS_TOOLS_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Dispatch a filesystem/shell tool request.
|
||||
*
|
||||
* tool_name: one of "fs_read", "fs_write", "fs_list", "fs_mkdir",
|
||||
* "fs_delete", "shell_exec"
|
||||
* params: cJSON object with the tool's parameters
|
||||
*
|
||||
* Returns a cJSON response in the same format as agent_tools_dispatch():
|
||||
* {"success":true,"data":{...}} or
|
||||
* {"success":false,"error":{"code":"...","message":"..."}}
|
||||
*
|
||||
* The caller frees the returned cJSON*. Returns NULL if tool_name is
|
||||
* not recognized (so the caller can fall through to other handlers).
|
||||
*/
|
||||
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params);
|
||||
|
||||
/* Returns TRUE if the given tool name is handled by this module. */
|
||||
int agent_fs_is_tool(const char *tool_name);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_FS_TOOLS_H */
|
||||
417
src/agent_llm.c
Normal file
417
src/agent_llm.c
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* agent_llm.c — OpenAI-compatible LLM HTTP client
|
||||
*
|
||||
* Uses libsoup-3.0's synchronous soup_session_send_and_read() to POST
|
||||
* a chat-completions request to an OpenAI-compatible endpoint, then
|
||||
* parses the JSON response with cJSON and returns the assistant's
|
||||
* message (text content + any tool_calls).
|
||||
*
|
||||
* A fresh SoupSession is created per call so this is safe to invoke
|
||||
* from a background thread (libsoup-3.0 sessions are not meant to be
|
||||
* shared across threads). The GTK main thread is never touched here.
|
||||
*/
|
||||
|
||||
#include "agent_llm.h"
|
||||
#include "agent_tool_catalog.h"
|
||||
|
||||
#include <libsoup/soup.h>
|
||||
#include <glib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── OpenAI tools helper ────────────────────────────────────────────── *
|
||||
*
|
||||
* build_tools_list() returns the catalog in MCP format:
|
||||
* [{"name":...,"description":...,"inputSchema":{...}}, ...]
|
||||
*
|
||||
* OpenAI wants each entry wrapped as:
|
||||
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
|
||||
*
|
||||
* We rebuild from tool_defs[] directly so the "parameters" field is a
|
||||
* fresh cJSON copy (the MCP version uses "inputSchema").
|
||||
*/
|
||||
cJSON *agent_llm_build_openai_tools(void) {
|
||||
cJSON *tools = cJSON_CreateArray();
|
||||
if (tools == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int i = 0; i < tool_defs_count; i++) {
|
||||
cJSON *entry = cJSON_CreateObject();
|
||||
if (entry == NULL) {
|
||||
cJSON_Delete(tools);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(entry, "type", "function");
|
||||
|
||||
cJSON *fn = cJSON_CreateObject();
|
||||
if (fn == NULL) {
|
||||
cJSON_Delete(entry);
|
||||
cJSON_Delete(tools);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddStringToObject(fn, "name", tool_defs[i].name);
|
||||
cJSON_AddStringToObject(fn, "description", tool_defs[i].description);
|
||||
|
||||
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
|
||||
if (schema) {
|
||||
cJSON_AddItemToObject(fn, "parameters", schema);
|
||||
} else {
|
||||
/* Fall back to an empty object so the field is always present. */
|
||||
cJSON_AddItemToObject(fn, "parameters", cJSON_CreateObject());
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(entry, "function", fn);
|
||||
cJSON_AddItemToArray(tools, entry);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/* ── Response lifecycle ─────────────────────────────────────────────── */
|
||||
|
||||
void agent_llm_response_free(agent_llm_response_t *resp) {
|
||||
if (resp == NULL) {
|
||||
return;
|
||||
}
|
||||
g_free(resp->content);
|
||||
if (resp->tool_calls) {
|
||||
cJSON_Delete(resp->tool_calls);
|
||||
}
|
||||
g_free(resp->finish_reason);
|
||||
g_free(resp);
|
||||
}
|
||||
|
||||
/* ── Internal: build the request body JSON ──────────────────────────── *
|
||||
*
|
||||
* Returns a newly-allocated JSON string (caller frees with g_free).
|
||||
* The caller's messages/tools arrays are referenced (not consumed) —
|
||||
* cJSON_AddItemReferenceToObject increments the refcount so the caller
|
||||
* retains ownership.
|
||||
*
|
||||
* Returns NULL on allocation failure.
|
||||
*/
|
||||
static char *build_request_body(const char *model,
|
||||
cJSON *messages,
|
||||
cJSON *tools) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(root, "model", model);
|
||||
|
||||
/* Reference the caller's array so we don't steal ownership. */
|
||||
cJSON_AddItemReferenceToObject(root, "messages", messages);
|
||||
|
||||
if (tools != NULL) {
|
||||
cJSON_AddItemReferenceToObject(root, "tools", tools);
|
||||
cJSON_AddStringToObject(root, "tool_choice", "auto");
|
||||
}
|
||||
|
||||
char *str = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return str;
|
||||
}
|
||||
|
||||
/* ── Internal: parse the response body into agent_llm_response_t ────── *
|
||||
*
|
||||
* body is the raw HTTP response body (NUL-terminated by caller).
|
||||
* Returns a newly-allocated agent_llm_response_t, or NULL on parse
|
||||
* failure / error response.
|
||||
*/
|
||||
static agent_llm_response_t *parse_response(const char *body) {
|
||||
cJSON *root = cJSON_Parse(body);
|
||||
if (root == NULL) {
|
||||
g_printerr("[agent_llm] failed to parse response JSON\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Some servers return {"error": {...}} on failure. */
|
||||
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
if (err) {
|
||||
char *err_str = cJSON_PrintUnformatted(err);
|
||||
g_printerr("[agent_llm] API error: %s\n",
|
||||
err_str ? err_str : "(unknown)");
|
||||
cJSON_free(err_str);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
|
||||
cJSON *choice0 = cJSON_GetArrayItem(choices, 0);
|
||||
if (choice0 == NULL) {
|
||||
g_printerr("[agent_llm] response has no choices[0]\n");
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
agent_llm_response_t *resp = g_new0(agent_llm_response_t, 1);
|
||||
if (resp == NULL) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON *message = cJSON_GetObjectItemCaseSensitive(choice0, "message");
|
||||
if (message) {
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content");
|
||||
if (cJSON_IsString(content) && content->valuestring != NULL) {
|
||||
resp->content = g_strdup(content->valuestring);
|
||||
} else {
|
||||
resp->content = NULL;
|
||||
}
|
||||
|
||||
cJSON *tool_calls = cJSON_GetObjectItemCaseSensitive(message, "tool_calls");
|
||||
if (tool_calls != NULL) {
|
||||
/* Detach a deep copy so the response outlives the parsed root. */
|
||||
resp->tool_calls = cJSON_Duplicate(tool_calls, 1);
|
||||
} else {
|
||||
resp->tool_calls = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON *finish = cJSON_GetObjectItemCaseSensitive(choice0, "finish_reason");
|
||||
if (cJSON_IsString(finish) && finish->valuestring != NULL) {
|
||||
resp->finish_reason = g_strdup(finish->valuestring);
|
||||
} else {
|
||||
resp->finish_reason = NULL;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/* ── Internal: normalize base URL ───────────────────────────────────── *
|
||||
*
|
||||
* Many OpenAI-compatible APIs expect the path prefix "/v1" before the
|
||||
* endpoint (e.g. https://api.openai.com/v1/models). If the user supplies
|
||||
* a base URL that already ends with "/v1" (or another "/vN" version
|
||||
* segment) we leave it alone; otherwise we append "/v1" so that
|
||||
* {base}/models and {base}/chat/completions resolve correctly.
|
||||
*
|
||||
* Returns a newly-allocated string (caller frees with g_free).
|
||||
*/
|
||||
static char *normalize_base_url(const char *base_url) {
|
||||
if (base_url == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Strip trailing slashes for consistent checking. */
|
||||
g_autofree char *trimmed = NULL;
|
||||
{
|
||||
size_t len = strlen(base_url);
|
||||
while (len > 0 && base_url[len - 1] == '/') {
|
||||
len--;
|
||||
}
|
||||
trimmed = g_strndup(base_url, len);
|
||||
}
|
||||
|
||||
size_t tlen = strlen(trimmed);
|
||||
|
||||
/* Already ends with "/v1"? */
|
||||
if (tlen >= 3 && strcmp(trimmed + tlen - 3, "/v1") == 0) {
|
||||
return g_strdup(trimmed);
|
||||
}
|
||||
|
||||
/* Also handle "/v2", "/v3" etc. — if the last path segment is
|
||||
* "v" followed by one or more digits, assume the user already
|
||||
* included a version prefix. */
|
||||
if (tlen > 0) {
|
||||
const char *slash = strrchr(trimmed, '/');
|
||||
if (slash != NULL) {
|
||||
const char *seg = slash + 1;
|
||||
if (seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9') {
|
||||
return g_strdup(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return g_strdup_printf("%s/v1", trimmed);
|
||||
}
|
||||
|
||||
/* ── Public: agent_llm_chat ─────────────────────────────────────────── */
|
||||
|
||||
agent_llm_response_t *agent_llm_chat(const char *base_url,
|
||||
const char *api_key,
|
||||
const char *model,
|
||||
cJSON *messages,
|
||||
cJSON *tools) {
|
||||
if (base_url == NULL || model == NULL || messages == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the full URL: {normalized_base_url}/chat/completions.
|
||||
* normalize_base_url() strips trailing slashes and appends "/v1"
|
||||
* if needed, so we never produce a "//" here. */
|
||||
g_autofree char *norm = normalize_base_url(base_url);
|
||||
g_autofree char *url = g_strdup_printf("%s/chat/completions", norm);
|
||||
|
||||
/* Build request body. */
|
||||
g_autofree char *body = build_request_body(model, messages, tools);
|
||||
if (body == NULL) {
|
||||
g_printerr("[agent_llm] failed to build request body\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Create the SoupMessage. */
|
||||
SoupMessage *msg = soup_message_new("POST", url);
|
||||
if (msg == NULL) {
|
||||
g_printerr("[agent_llm] invalid URL: %s\n", url);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
soup_message_headers_set_content_type(
|
||||
soup_message_get_request_headers(msg), "application/json", NULL);
|
||||
|
||||
if (api_key != NULL && api_key[0] != '\0') {
|
||||
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
|
||||
soup_message_headers_append(
|
||||
soup_message_get_request_headers(msg), "Authorization", bearer);
|
||||
}
|
||||
|
||||
/* Set the request body from a GBytes. libsoup-3.0 takes ownership
|
||||
* of the GBytes; the g_autofree `body` string is freed at scope exit. */
|
||||
GBytes *req_bytes = g_bytes_new(body, strlen(body));
|
||||
soup_message_set_request_body_from_bytes(msg, "application/json", req_bytes);
|
||||
g_bytes_unref(req_bytes);
|
||||
|
||||
/* Send synchronously. A fresh session per call keeps things
|
||||
* thread-safe without any global state. */
|
||||
g_print("[agent_llm] POST %s (model=%s, %d messages, %d tools)\n",
|
||||
url, model, cJSON_GetArraySize(messages),
|
||||
tools ? cJSON_GetArraySize(tools) : 0);
|
||||
SoupSession *session = soup_session_new();
|
||||
/* Set a generous timeout (300s) to accommodate slow CPU-only models
|
||||
* and cold-start loading. The default libsoup timeout is 60s, which
|
||||
* is too short for large local models. */
|
||||
g_object_set(session, "timeout", 300, NULL);
|
||||
GError *error = NULL;
|
||||
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
|
||||
|
||||
agent_llm_response_t *result = NULL;
|
||||
|
||||
if (error != NULL) {
|
||||
g_printerr("[agent_llm] HTTP transport error: %s\n",
|
||||
error->message);
|
||||
g_error_free(error);
|
||||
} else {
|
||||
guint status = soup_message_get_status(msg);
|
||||
if (status != SOUP_STATUS_OK) {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(resp_bytes, &size);
|
||||
g_printerr("[agent_llm] HTTP %u: %.*s\n",
|
||||
status, (int)size, data ? data : "");
|
||||
} else {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(resp_bytes, &size);
|
||||
/* Make a NUL-terminated copy for cJSON. */
|
||||
g_autofree char *body_str = g_strndup(data ? data : "", size);
|
||||
result = parse_response(body_str);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp_bytes) {
|
||||
g_bytes_unref(resp_bytes);
|
||||
}
|
||||
g_object_unref(msg);
|
||||
g_object_unref(session);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Public: agent_llm_list_models ──────────────────────────────────── *
|
||||
*
|
||||
* Fetch the list of available models from an OpenAI-compatible API.
|
||||
* Calls GET {base_url}/models with an Authorization: Bearer header
|
||||
* (if api_key is non-NULL and non-empty). Parses the "data" array and
|
||||
* returns a cJSON array of model ID strings. Returns NULL on error.
|
||||
* Caller must cJSON_Delete() the returned array.
|
||||
*/
|
||||
cJSON *agent_llm_list_models(const char *base_url, const char *api_key) {
|
||||
if (base_url == NULL || base_url[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the full URL: {normalized_base_url}/models.
|
||||
* normalize_base_url() strips trailing slashes and appends "/v1"
|
||||
* if needed, so we never produce a "//" here. */
|
||||
g_autofree char *norm = normalize_base_url(base_url);
|
||||
g_autofree char *url = g_strdup_printf("%s/models", norm);
|
||||
|
||||
SoupMessage *msg = soup_message_new("GET", url);
|
||||
if (msg == NULL) {
|
||||
g_printerr("[agent_llm] invalid URL: %s\n", url);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (api_key != NULL && api_key[0] != '\0') {
|
||||
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
|
||||
soup_message_headers_append(
|
||||
soup_message_get_request_headers(msg), "Authorization", bearer);
|
||||
}
|
||||
|
||||
SoupSession *session = soup_session_new();
|
||||
GError *error = NULL;
|
||||
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
|
||||
|
||||
cJSON *models = NULL;
|
||||
|
||||
if (error != NULL) {
|
||||
g_printerr("[agent_llm] list_models transport error: %s\n",
|
||||
error->message);
|
||||
g_error_free(error);
|
||||
} else {
|
||||
guint status = soup_message_get_status(msg);
|
||||
if (status != SOUP_STATUS_OK) {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(resp_bytes, &size);
|
||||
g_printerr("[agent_llm] list_models HTTP %u: %.*s\n",
|
||||
status, (int)size, data ? data : "");
|
||||
} else {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(resp_bytes, &size);
|
||||
g_autofree char *body_str = g_strndup(data ? data : "", size);
|
||||
|
||||
cJSON *root = cJSON_Parse(body_str);
|
||||
if (root == NULL) {
|
||||
g_printerr("[agent_llm] list_models: failed to parse JSON\n");
|
||||
} else {
|
||||
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
if (err) {
|
||||
g_printerr("[agent_llm] list_models: API returned error\n");
|
||||
} else {
|
||||
cJSON *data_arr = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
if (cJSON_IsArray(data_arr)) {
|
||||
models = cJSON_CreateArray();
|
||||
if (models != NULL) {
|
||||
cJSON *entry;
|
||||
cJSON_ArrayForEach(entry, data_arr) {
|
||||
cJSON *id = cJSON_GetObjectItemCaseSensitive(entry, "id");
|
||||
if (cJSON_IsString(id) && id->valuestring != NULL) {
|
||||
cJSON_AddItemToArray(models,
|
||||
cJSON_CreateString(id->valuestring));
|
||||
}
|
||||
}
|
||||
/* If no IDs were found, treat as error. */
|
||||
if (cJSON_GetArraySize(models) == 0) {
|
||||
cJSON_Delete(models);
|
||||
models = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resp_bytes) {
|
||||
g_bytes_unref(resp_bytes);
|
||||
}
|
||||
g_object_unref(msg);
|
||||
g_object_unref(session);
|
||||
|
||||
return models;
|
||||
}
|
||||
86
src/agent_llm.h
Normal file
86
src/agent_llm.h
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* agent_llm.h — OpenAI-compatible LLM HTTP client
|
||||
*
|
||||
* Sends chat-completions requests to an OpenAI-compatible endpoint
|
||||
* (OpenAI, OpenRouter, Ollama, LM Studio, Groq, etc.) using libsoup-3.0,
|
||||
* parses the JSON response, and returns the assistant's message (text
|
||||
* content + any tool_calls).
|
||||
*
|
||||
* The HTTP call is synchronous (soup_session_send_and_read) and is
|
||||
* intended to be called from a background thread — not the GTK main
|
||||
* thread. A fresh SoupSession is created per call so there are no
|
||||
* cross-thread sharing issues.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_LLM_H
|
||||
#define AGENT_LLM_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Response ──────────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char *content; /* assistant text (may be NULL or empty) */
|
||||
cJSON *tool_calls; /* JSON array of tool call objects, or NULL */
|
||||
char *finish_reason; /* "stop", "tool_calls", "length", etc. */
|
||||
} agent_llm_response_t;
|
||||
|
||||
/*
|
||||
* Call an OpenAI-compatible chat completions endpoint.
|
||||
*
|
||||
* base_url — e.g. "https://api.openai.com/v1" or "http://localhost:11434/v1"
|
||||
* api_key — bearer token (may be NULL for local servers like Ollama)
|
||||
* model — model name, e.g. "gpt-4o", "llama3.1", etc.
|
||||
* messages — cJSON array of message objects (role/content/tool_calls/tool_call_id)
|
||||
* tools — cJSON array of tool definitions (OpenAI format), or NULL
|
||||
*
|
||||
* Returns a newly-allocated agent_llm_response_t. Caller must free with
|
||||
* agent_llm_response_free().
|
||||
*
|
||||
* On error, returns NULL (caller should handle gracefully).
|
||||
*/
|
||||
agent_llm_response_t *agent_llm_chat(const char *base_url,
|
||||
const char *api_key,
|
||||
const char *model,
|
||||
cJSON *messages,
|
||||
cJSON *tools);
|
||||
|
||||
/*
|
||||
* Free an agent_llm_response_t returned by agent_llm_chat().
|
||||
* Safe to call with NULL.
|
||||
*/
|
||||
void agent_llm_response_free(agent_llm_response_t *resp);
|
||||
|
||||
/*
|
||||
* Build the OpenAI-format "tools" array from the shared tool catalog
|
||||
* (agent_tool_catalog.h). Each entry is wrapped as:
|
||||
*
|
||||
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
|
||||
*
|
||||
* Returns a newly-allocated cJSON array. Caller frees with cJSON_Delete().
|
||||
* Returns NULL if the catalog is empty or on allocation failure.
|
||||
*/
|
||||
cJSON *agent_llm_build_openai_tools(void);
|
||||
|
||||
/*
|
||||
* Fetch the list of available models from an OpenAI-compatible API.
|
||||
* Calls GET {base_url}/models with the Authorization header.
|
||||
*
|
||||
* base_url — e.g. "https://api.ppq.ai"
|
||||
* api_key — bearer token (may be NULL for local servers)
|
||||
*
|
||||
* Returns a cJSON array of model ID strings (newly allocated), e.g.:
|
||||
* ["gpt-4o", "gpt-4o-mini", "llama3.1"]
|
||||
* Returns NULL on error. Caller must cJSON_Delete().
|
||||
*/
|
||||
cJSON *agent_llm_list_models(const char *base_url, const char *api_key);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_LLM_H */
|
||||
@@ -20,6 +20,7 @@
|
||||
* We access it via extern functions that main.c provides.
|
||||
*/
|
||||
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex,
|
||||
key_store_method_t method, gboolean readonly);
|
||||
extern void app_clear_signer(void);
|
||||
extern nostr_signer_t *app_get_signer(void);
|
||||
@@ -27,6 +28,12 @@ extern const char *app_get_pubkey_hex(void);
|
||||
extern key_store_method_t app_get_method(void);
|
||||
extern gboolean app_get_readonly(void);
|
||||
|
||||
/* Defined in main.c. Tears down the current user's web state (closes all
|
||||
* tabs, destroys sidebar webviews, wipes WebKit cookies/cache/localStorage/
|
||||
* service workers/favicons) so the next identity starts clean. See
|
||||
* plans/webkit-data-isolation.md. */
|
||||
extern void identity_teardown_web_state(void);
|
||||
|
||||
/* Track whether the agent (not the GTK dialog) performed the login. */
|
||||
static gboolean g_agent_performed_login = FALSE;
|
||||
|
||||
@@ -131,13 +138,18 @@ static cJSON *login_local(cJSON *params) {
|
||||
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
|
||||
return make_error("DERIVE_FAILED", "Failed to derive public key");
|
||||
}
|
||||
char privkey_hex_out[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex_out[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
|
||||
@@ -169,13 +181,18 @@ static cJSON *login_generate(cJSON *params) {
|
||||
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
|
||||
nsec[0] = '\0';
|
||||
}
|
||||
char privkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_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);
|
||||
@@ -206,13 +223,18 @@ static cJSON *login_seed(cJSON *params) {
|
||||
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
||||
}
|
||||
pubkey_hex[64] = '\0';
|
||||
char privkey_hex[65];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
|
||||
}
|
||||
privkey_hex[64] = '\0';
|
||||
|
||||
nostr_signer_t *signer = nostr_signer_local(privkey);
|
||||
if (signer == NULL) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
|
||||
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
|
||||
@@ -254,8 +276,8 @@ static cJSON *login_readonly(cJSON *params) {
|
||||
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
|
||||
}
|
||||
|
||||
/* Read-only: no signer created. */
|
||||
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
|
||||
/* Read-only: no signer created. No privkey available. */
|
||||
app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE);
|
||||
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
|
||||
|
||||
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
|
||||
@@ -290,7 +312,13 @@ static cJSON *login_nip46(cJSON *params) {
|
||||
return make_error("SIGNER_FAILED", "Failed to create client signer");
|
||||
}
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
|
||||
/* NIP-46: the client_privkey is a throwaway session key for the bunker
|
||||
* protocol, not the user's identity key. The user's actual signing key
|
||||
* lives on the remote bunker. We do NOT use it for bookmark HMAC d tags
|
||||
* (the bunker signer handles NIP-44 content encryption; d-tag derivation
|
||||
* would require the user's real privkey, which we don't have). Bookmarks
|
||||
* can still be loaded from the db in read-only fashion. */
|
||||
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
|
||||
@@ -357,6 +385,12 @@ static cJSON *login_nsigner(cJSON *params) {
|
||||
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
|
||||
}
|
||||
|
||||
/* n_signer's serial/TCP transports require a kind-27235 auth envelope on
|
||||
* every request. Install the default caller identity (matching n_signer's
|
||||
* webserial demo) before issuing any verbs. Without this the device
|
||||
* rejects the call with an auth error that surfaces as "code -310". */
|
||||
key_store_nsigner_set_default_auth(signer);
|
||||
|
||||
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
nostr_signer_free(signer);
|
||||
@@ -367,14 +401,37 @@ static cJSON *login_nsigner(cJSON *params) {
|
||||
char pubkey_hex[65];
|
||||
rc = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
const char *err_str = nostr_strerror(rc);
|
||||
g_printerr("[agent-login] n_signer get_public_key failed: rc=%d (%s)\n",
|
||||
rc, err_str ? err_str : "?");
|
||||
nostr_signer_free(signer);
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
|
||||
if (rc == NOSTR_ERROR_NIP46_AUTH_CHALLENGE) {
|
||||
/* Device RPC codes 2010-2017: auth-related rejection. Most
|
||||
* commonly the kind-27235 caller auth envelope was missing or
|
||||
* denied (the default caller identity is installed automatically,
|
||||
* but the device's policy may still deny it). Can also mean the
|
||||
* device is requesting on-device approval. */
|
||||
return make_error("NSIGNER_AUTH_REJECTED",
|
||||
"n_signer auth rejected (code -310). The caller "
|
||||
"auth envelope was missing or denied, or the "
|
||||
"device is requesting on-device approval. Confirm "
|
||||
"any prompt on the hardware signer, then retry.");
|
||||
}
|
||||
{
|
||||
char msg[256];
|
||||
snprintf(msg, sizeof(msg),
|
||||
"Failed to get pubkey from n_signer (rc=%d: %s).",
|
||||
rc, err_str ? err_str : "unknown");
|
||||
return make_error("NSIGNER_PUBKEY", msg);
|
||||
}
|
||||
}
|
||||
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
|
||||
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
|
||||
/* n_signer: the privkey never leaves the hardware device, so we can't
|
||||
* derive HMAC d tags client-side. Bookmarks fall back to read-only. */
|
||||
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE);
|
||||
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
|
||||
|
||||
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
|
||||
@@ -459,14 +516,26 @@ cJSON *agent_login(cJSON *params) {
|
||||
}
|
||||
|
||||
cJSON *agent_logout(void) {
|
||||
/* Tear down the current user's web state (closes all tabs, destroys
|
||||
* sidebar webviews, wipes WebKit cookies/cache/localStorage/service
|
||||
* workers/favicons) so the next login starts with a clean web
|
||||
* session. Must happen before clearing the signer. See
|
||||
* plans/webkit-data-isolation.md. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
app_clear_signer();
|
||||
nostr_bridge_set_signer(NULL, "", TRUE);
|
||||
key_store_clear();
|
||||
g_print("[agent-login] Logged out\n");
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
cJSON *agent_switch_identity(cJSON *params) {
|
||||
/* Tear down the previous user's web state BEFORE freeing the signer
|
||||
* and logging in the new identity, so the new user starts with a
|
||||
* clean web session (no leftover cookies/localStorage/tabs from the
|
||||
* previous user). See plans/webkit-data-isolation.md. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
/* Free the old signer first. */
|
||||
app_clear_signer();
|
||||
/* Then login with the new identity. */
|
||||
|
||||
484
src/agent_loop.c
Normal file
484
src/agent_loop.c
Normal file
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* agent_loop.c — ReAct tool-call loop on a background GThread
|
||||
*
|
||||
* Implements the standard ReAct loop:
|
||||
* 1. Load chat history from agent_chat_store_get_messages().
|
||||
* 2. Prepend a system prompt message (must be first in the array).
|
||||
* 3. Call the LLM via agent_llm_chat().
|
||||
* 4. If the response contains tool_calls, dispatch each:
|
||||
* - Browser tools → hop to the GTK main thread via g_idle_add() +
|
||||
* GAsyncQueue (WebKitGTK is not thread-safe).
|
||||
* - Filesystem/shell tools → run directly on this thread via
|
||||
* agent_fs_tools_dispatch().
|
||||
* 5. Append tool results to the chat store.
|
||||
* 6. Repeat until no more tool_calls, the iteration cap is reached, or
|
||||
* the cancel flag is set.
|
||||
* 7. Persist the final assistant message and update session status.
|
||||
*
|
||||
* See plans/embedded-agent.md for the full concurrency model.
|
||||
*/
|
||||
|
||||
#include "agent_loop.h"
|
||||
#include "agent_llm.h"
|
||||
#include "agent_chat_store.h"
|
||||
#include "agent_fs_tools.h"
|
||||
#include "agent_tools.h"
|
||||
#include "agent_skills.h"
|
||||
#include "agent_server.h"
|
||||
#include "settings.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Context ────────────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
/* Atomic flags */
|
||||
volatile gint cancel_flag;
|
||||
volatile gint running; /* gboolean as gint */
|
||||
|
||||
/* Status (protected by status_lock) */
|
||||
GMutex status_lock;
|
||||
agent_loop_state_t state;
|
||||
int iteration;
|
||||
char *current_tool; /* tool name being executed */
|
||||
char *last_message; /* last assistant text */
|
||||
char *error; /* error message */
|
||||
|
||||
/* Thread handle */
|
||||
GThread *thread;
|
||||
} agent_loop_ctx_t;
|
||||
|
||||
static agent_loop_ctx_t g_ctx = {0};
|
||||
|
||||
/* ── Status helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Map a state enum to its string name for JSON events. */
|
||||
static const char *
|
||||
state_name(agent_loop_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case AGENT_LOOP_IDLE: return "idle";
|
||||
case AGENT_LOOP_THINKING: return "thinking";
|
||||
case AGENT_LOOP_TOOL_CALL: return "tool_call";
|
||||
case AGENT_LOOP_COMPLETE: return "complete";
|
||||
case AGENT_LOOP_ERROR: return "error";
|
||||
case AGENT_LOOP_CANCELLED: return "cancelled";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/* Emit the current status as a WebSocket push event so the chat UI
|
||||
* can update instantly without polling. Must be called WITHOUT the
|
||||
* mutex held (it takes it itself). */
|
||||
static void
|
||||
emit_status_event(void)
|
||||
{
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "state", state_name(g_ctx.state));
|
||||
cJSON_AddNumberToObject(data, "iteration", g_ctx.iteration);
|
||||
if (g_ctx.current_tool)
|
||||
cJSON_AddStringToObject(data, "current_tool", g_ctx.current_tool);
|
||||
else
|
||||
cJSON_AddNullToObject(data, "current_tool");
|
||||
if (g_ctx.last_message)
|
||||
cJSON_AddStringToObject(data, "last_message", g_ctx.last_message);
|
||||
else
|
||||
cJSON_AddNullToObject(data, "last_message");
|
||||
if (g_ctx.error)
|
||||
cJSON_AddStringToObject(data, "error", g_ctx.error);
|
||||
else
|
||||
cJSON_AddNullToObject(data, "error");
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
|
||||
/* agent_server_emit_event takes ownership of data. */
|
||||
agent_server_emit_event("agent_status", data);
|
||||
}
|
||||
|
||||
static void
|
||||
set_status(agent_loop_state_t state, int iter,
|
||||
const char *tool, const char *msg, const char *err)
|
||||
{
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
g_ctx.state = state;
|
||||
g_ctx.iteration = iter;
|
||||
if (tool) {
|
||||
g_free(g_ctx.current_tool);
|
||||
g_ctx.current_tool = g_strdup(tool);
|
||||
} else {
|
||||
/* Clear tool name when not in a tool-call state. */
|
||||
g_free(g_ctx.current_tool);
|
||||
g_ctx.current_tool = NULL;
|
||||
}
|
||||
if (msg) {
|
||||
g_free(g_ctx.last_message);
|
||||
g_ctx.last_message = g_strdup(msg);
|
||||
}
|
||||
/* Only set the error field when err is non-NULL. When err is NULL
|
||||
* and we're transitioning to a non-error state, clear any stale
|
||||
* error so the UI doesn't show an old error message. */
|
||||
if (err) {
|
||||
g_free(g_ctx.error);
|
||||
g_ctx.error = g_strdup(err);
|
||||
} else if (state != AGENT_LOOP_ERROR) {
|
||||
g_free(g_ctx.error);
|
||||
g_ctx.error = NULL;
|
||||
}
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
emit_status_event();
|
||||
}
|
||||
|
||||
static void
|
||||
set_state(agent_loop_state_t state)
|
||||
{
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
g_ctx.state = state;
|
||||
/* Clear stale error when transitioning to a non-error state. */
|
||||
if (state != AGENT_LOOP_ERROR) {
|
||||
g_free(g_ctx.error);
|
||||
g_ctx.error = NULL;
|
||||
}
|
||||
/* Clear tool name when leaving the tool_call state. */
|
||||
if (state != AGENT_LOOP_TOOL_CALL) {
|
||||
g_free(g_ctx.current_tool);
|
||||
g_ctx.current_tool = NULL;
|
||||
}
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
emit_status_event();
|
||||
}
|
||||
|
||||
static void
|
||||
set_error(const char *msg)
|
||||
{
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
g_ctx.state = AGENT_LOOP_ERROR;
|
||||
g_free(g_ctx.error);
|
||||
g_ctx.error = g_strdup(msg);
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
/* Emit a status event so the UI learns about the error immediately.
|
||||
* Without this, the chat page stays stuck on "Thinking..." because
|
||||
* the WebSocket push never fires and the polling fallback may have
|
||||
* been stopped when the WebSocket connected. */
|
||||
emit_status_event();
|
||||
}
|
||||
|
||||
/* ── Build messages array with system prompt first ──────────────────── */
|
||||
|
||||
/*
|
||||
* Build the messages array for the LLM: a system message first, then
|
||||
* all messages from the chat store. Returns a newly-allocated cJSON
|
||||
* array (caller frees with cJSON_Delete), or NULL on error.
|
||||
*/
|
||||
static cJSON *
|
||||
build_messages_with_system(const char *system_prompt)
|
||||
{
|
||||
cJSON *history = agent_chat_store_get_messages();
|
||||
if (history == NULL)
|
||||
return NULL;
|
||||
|
||||
cJSON *messages = cJSON_CreateArray();
|
||||
if (messages == NULL) {
|
||||
cJSON_Delete(history);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* System prompt must be the first message */
|
||||
cJSON *sys_msg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(sys_msg, "role", "system");
|
||||
cJSON_AddStringToObject(sys_msg, "content", system_prompt);
|
||||
cJSON_AddItemToArray(messages, sys_msg);
|
||||
|
||||
/* Copy all history messages into the new array */
|
||||
cJSON *msg;
|
||||
cJSON_ArrayForEach(msg, history) {
|
||||
cJSON_AddItemToArray(messages, cJSON_Duplicate(msg, 1));
|
||||
}
|
||||
|
||||
cJSON_Delete(history);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/* ── Background thread ──────────────────────────────────────────────── */
|
||||
|
||||
static gpointer
|
||||
agent_loop_thread(gpointer data)
|
||||
{
|
||||
char *user_message = (char *)data;
|
||||
g_print("[agent-loop] Thread started, message: %s\n", user_message);
|
||||
|
||||
/* 1. Add user message to chat store */
|
||||
agent_chat_store_add_user_message(user_message);
|
||||
g_free(user_message);
|
||||
/* Notify UI that a message was added. */
|
||||
cJSON *umsg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(umsg, "role", "user");
|
||||
agent_server_emit_event("agent_message", umsg);
|
||||
|
||||
/* 2. Get provider settings (copy — they could change while running) */
|
||||
const browser_settings_t *s = settings_get();
|
||||
char base_url[512], api_key[512], model[128];
|
||||
g_strlcpy(base_url, s->agent_llm_base_url, sizeof(base_url));
|
||||
g_strlcpy(api_key, s->agent_llm_api_key, sizeof(api_key));
|
||||
g_strlcpy(model, s->agent_llm_model, sizeof(model));
|
||||
g_print("[agent-loop] base_url=%s model=%s api_key=%s max_iter=%d\n",
|
||||
base_url, model, api_key[0] ? "(set)" : "(empty)", s->agent_max_iterations);
|
||||
|
||||
/* Build the system prompt. agent_skills_build_system_prompt() now
|
||||
* always returns a non-NULL string: it starts with the Sovereign
|
||||
* Browser Skill template (from settings) as the base, then
|
||||
* appends any selected Nostr skills' templates. */
|
||||
char *system_prompt = agent_skills_build_system_prompt();
|
||||
if (system_prompt == NULL) {
|
||||
/* Defensive fallback — should never happen. */
|
||||
system_prompt = g_strdup(SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
|
||||
}
|
||||
g_print("[agent-loop] Using system prompt (%zu bytes)\n",
|
||||
strlen(system_prompt));
|
||||
|
||||
int max_iter = s->agent_max_iterations;
|
||||
if (max_iter <= 0)
|
||||
max_iter = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
|
||||
|
||||
/* Empty API key → pass NULL (local servers like Ollama don't need one) */
|
||||
const char *key_arg = (api_key[0] != '\0') ? api_key : NULL;
|
||||
|
||||
/* 3. Build OpenAI tools array (built once, reused each iteration) */
|
||||
cJSON *tools = agent_llm_build_openai_tools();
|
||||
|
||||
/* 4. ReAct loop */
|
||||
for (int iter = 0; iter < max_iter; iter++) {
|
||||
/* Check cancel */
|
||||
if (g_atomic_int_get(&g_ctx.cancel_flag)) {
|
||||
set_state(AGENT_LOOP_CANCELLED);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Update status: thinking */
|
||||
set_status(AGENT_LOOP_THINKING, iter, NULL, NULL, NULL);
|
||||
|
||||
/* Build messages: system prompt first, then chat history */
|
||||
cJSON *messages = build_messages_with_system(system_prompt);
|
||||
if (messages == NULL) {
|
||||
set_error("Failed to load messages");
|
||||
break;
|
||||
}
|
||||
|
||||
/* Call LLM (blocking HTTP on this thread) */
|
||||
g_print("[agent-loop] iter %d: calling LLM...\n", iter);
|
||||
agent_llm_response_t *resp = agent_llm_chat(base_url, key_arg,
|
||||
model, messages, tools);
|
||||
cJSON_Delete(messages);
|
||||
|
||||
if (resp == NULL) {
|
||||
g_print("[agent-loop] iter %d: LLM call returned NULL\n", iter);
|
||||
set_error("LLM API call failed");
|
||||
break;
|
||||
}
|
||||
g_print("[agent-loop] iter %d: LLM responded, finish=%s, content=%s, tool_calls=%d\n",
|
||||
iter, resp->finish_reason ? resp->finish_reason : "(null)",
|
||||
resp->content ? resp->content : "(null)",
|
||||
resp->tool_calls ? cJSON_GetArraySize(resp->tool_calls) : 0);
|
||||
|
||||
/* Persist assistant message */
|
||||
char *tool_calls_str = (resp->tool_calls != NULL)
|
||||
? cJSON_PrintUnformatted(resp->tool_calls) : NULL;
|
||||
agent_chat_store_add_assistant_message(resp->content, tool_calls_str);
|
||||
g_free(tool_calls_str);
|
||||
/* Notify UI that a message was added. */
|
||||
cJSON *amsg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(amsg, "role", "assistant");
|
||||
agent_server_emit_event("agent_message", amsg);
|
||||
|
||||
/* Update last_message status */
|
||||
set_status(AGENT_LOOP_THINKING, iter, NULL,
|
||||
resp->content ? resp->content : "", NULL);
|
||||
|
||||
/* If no tool_calls, we're done */
|
||||
if (resp->tool_calls == NULL ||
|
||||
cJSON_GetArraySize(resp->tool_calls) == 0) {
|
||||
agent_llm_response_free(resp);
|
||||
set_state(AGENT_LOOP_COMPLETE);
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* Dispatch each tool call */
|
||||
cJSON *tc;
|
||||
cJSON_ArrayForEach(tc, resp->tool_calls) {
|
||||
if (g_atomic_int_get(&g_ctx.cancel_flag))
|
||||
break;
|
||||
|
||||
cJSON *fn = cJSON_GetObjectItem(tc, "function");
|
||||
const char *tool_name = fn
|
||||
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "name"))
|
||||
: NULL;
|
||||
const char *args_str = fn
|
||||
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "arguments"))
|
||||
: NULL;
|
||||
const char *tc_id = cJSON_GetStringValue(cJSON_GetObjectItem(tc, "id"));
|
||||
|
||||
cJSON *args = (args_str != NULL)
|
||||
? cJSON_Parse(args_str) : cJSON_CreateObject();
|
||||
if (args == NULL)
|
||||
args = cJSON_CreateObject();
|
||||
|
||||
set_status(AGENT_LOOP_TOOL_CALL, iter,
|
||||
tool_name ? tool_name : "?", NULL, NULL);
|
||||
g_print("[agent-loop] iter %d: tool %s (args: %s)\n",
|
||||
iter, tool_name ? tool_name : "?",
|
||||
args_str ? args_str : "(null)");
|
||||
|
||||
/* Dispatch: fs/shell tools and browser tools both run on
|
||||
* this background thread. The browser tools use the same
|
||||
* sync JS eval path (conn=NULL) as the MCP HTTP handler,
|
||||
* which calls gtk_main_iteration() to pump the main loop
|
||||
* while waiting for the async JS result. This is safe to
|
||||
* call from a background thread — the main loop keeps
|
||||
* running and processes the JS eval callback. */
|
||||
cJSON *result;
|
||||
if (tool_name != NULL && agent_fs_is_tool(tool_name)) {
|
||||
result = agent_fs_tools_dispatch(tool_name, args);
|
||||
} else {
|
||||
/* Build the request JSON and dispatch directly (same
|
||||
* as the MCP HTTP handler does from the libsoup thread). */
|
||||
cJSON *request = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(request, "tool",
|
||||
tool_name ? tool_name : "");
|
||||
cJSON_AddItemToObject(request, "params", args);
|
||||
args = NULL; /* transferred to request */
|
||||
result = agent_tools_dispatch(request, NULL);
|
||||
cJSON_Delete(request);
|
||||
}
|
||||
if (args) cJSON_Delete(args);
|
||||
|
||||
/* Get result JSON string */
|
||||
char *result_str = (result != NULL)
|
||||
? cJSON_PrintUnformatted(result) : g_strdup("{}");
|
||||
cJSON_Delete(result);
|
||||
|
||||
/* Persist tool result */
|
||||
agent_chat_store_add_tool_result(
|
||||
tc_id ? tc_id : "", result_str ? result_str : "{}");
|
||||
g_free(result_str);
|
||||
/* Notify UI that a tool result was added. */
|
||||
cJSON *tmsg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(tmsg, "role", "tool");
|
||||
agent_server_emit_event("agent_message", tmsg);
|
||||
}
|
||||
|
||||
agent_llm_response_free(resp);
|
||||
}
|
||||
|
||||
/* If we exited the loop without completing or erroring, we hit the
|
||||
* iteration cap — treat as complete (the LLM was still working). */
|
||||
{
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
if (g_ctx.state != AGENT_LOOP_CANCELLED &&
|
||||
g_ctx.state != AGENT_LOOP_ERROR) {
|
||||
g_ctx.state = AGENT_LOOP_COMPLETE;
|
||||
}
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
}
|
||||
|
||||
done:
|
||||
g_free(system_prompt);
|
||||
cJSON_Delete(tools);
|
||||
g_atomic_int_set(&g_ctx.running, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Lazily initialize the mutex on first use. g_mutex_init() is idempotent
|
||||
* enough for our purposes — we guard with a g_once_init_enter block so
|
||||
* it only happens once.
|
||||
*/
|
||||
static void
|
||||
ensure_mutex_init(void)
|
||||
{
|
||||
static gsize initialized = 0;
|
||||
if (g_once_init_enter(&initialized)) {
|
||||
g_mutex_init(&g_ctx.status_lock);
|
||||
g_ctx.state = AGENT_LOOP_IDLE;
|
||||
g_once_init_leave(&initialized, 1);
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
agent_loop_run(const char *user_message)
|
||||
{
|
||||
g_print("[agent-loop] agent_loop_run called: %s\n",
|
||||
user_message ? user_message : "(null)");
|
||||
if (user_message == NULL)
|
||||
return -1;
|
||||
|
||||
ensure_mutex_init();
|
||||
|
||||
if (g_atomic_int_get(&g_ctx.running)) {
|
||||
g_print("[agent-loop] already running, rejecting\n");
|
||||
return -1; /* already running */
|
||||
}
|
||||
|
||||
/* Check that a provider is configured (base URL + model).
|
||||
* An empty API key is allowed (local servers like Ollama). */
|
||||
const browser_settings_t *s = settings_get();
|
||||
if (s->agent_llm_base_url[0] == '\0' || s->agent_llm_model[0] == '\0') {
|
||||
g_print("[agent-loop] no provider configured: base_url=%s model=%s\n",
|
||||
s->agent_llm_base_url, s->agent_llm_model);
|
||||
set_error("No LLM provider configured (set base URL and model on "
|
||||
"sovereign://agents)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Reset cancel flag */
|
||||
g_atomic_int_set(&g_ctx.cancel_flag, 0);
|
||||
|
||||
/* Reset status */
|
||||
set_status(AGENT_LOOP_THINKING, 0, NULL, NULL, NULL);
|
||||
|
||||
g_atomic_int_set(&g_ctx.running, 1);
|
||||
|
||||
/* Spawn background thread (takes ownership of the duplicated string) */
|
||||
g_ctx.thread = g_thread_new("agent-loop", agent_loop_thread,
|
||||
g_strdup(user_message));
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
agent_loop_cancel(void)
|
||||
{
|
||||
g_atomic_int_set(&g_ctx.cancel_flag, 1);
|
||||
}
|
||||
|
||||
gboolean
|
||||
agent_loop_is_running(void)
|
||||
{
|
||||
return g_atomic_int_get(&g_ctx.running) != 0;
|
||||
}
|
||||
|
||||
void
|
||||
agent_loop_get_status(agent_loop_state_t *state_out,
|
||||
int *iteration_out,
|
||||
char **current_tool_out,
|
||||
char **last_message_out,
|
||||
char **error_out)
|
||||
{
|
||||
ensure_mutex_init();
|
||||
|
||||
g_mutex_lock(&g_ctx.status_lock);
|
||||
|
||||
if (state_out)
|
||||
*state_out = g_ctx.state;
|
||||
if (iteration_out)
|
||||
*iteration_out = g_ctx.iteration;
|
||||
if (current_tool_out)
|
||||
*current_tool_out = g_strdup(g_ctx.current_tool);
|
||||
if (last_message_out)
|
||||
*last_message_out = g_strdup(g_ctx.last_message);
|
||||
if (error_out)
|
||||
*error_out = g_strdup(g_ctx.error);
|
||||
|
||||
g_mutex_unlock(&g_ctx.status_lock);
|
||||
}
|
||||
81
src/agent_loop.h
Normal file
81
src/agent_loop.h
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* agent_loop.h — ReAct tool-call loop for the embedded agent
|
||||
*
|
||||
* Runs the standard ReAct (reason → act → observe) loop on a background
|
||||
* GThread so the GTK main thread stays responsive during long LLM calls
|
||||
* and multi-step tool sequences.
|
||||
*
|
||||
* Threading model (see plans/embedded-agent.md):
|
||||
* - LLM HTTP calls block on the background thread.
|
||||
* - Browser tools (snapshot, click, eval, …) hop to the GTK main thread
|
||||
* via g_idle_add() + GAsyncQueue because WebKitGTK is not thread-safe.
|
||||
* - Filesystem/shell tools run directly on the background thread.
|
||||
* - SQLite writes use the FULLMUTEX connection and are safe from any thread.
|
||||
* - Cancellation is via an atomic flag checked at the top of each iteration.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_LOOP_H
|
||||
#define AGENT_LOOP_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Status states for the agent loop.
|
||||
*/
|
||||
typedef enum {
|
||||
AGENT_LOOP_IDLE,
|
||||
AGENT_LOOP_THINKING,
|
||||
AGENT_LOOP_TOOL_CALL,
|
||||
AGENT_LOOP_COMPLETE,
|
||||
AGENT_LOOP_ERROR,
|
||||
AGENT_LOOP_CANCELLED
|
||||
} agent_loop_state_t;
|
||||
|
||||
/*
|
||||
* Start the agent loop for a user message. This:
|
||||
* 1. Adds the user message to the chat store.
|
||||
* 2. Spawns a background GThread that runs the ReAct loop.
|
||||
* 3. Returns immediately (non-blocking).
|
||||
*
|
||||
* Returns 0 on success, -1 on error (e.g. already running, no provider
|
||||
* configured).
|
||||
*/
|
||||
int agent_loop_run(const char *user_message);
|
||||
|
||||
/*
|
||||
* Request cancellation of the running agent loop.
|
||||
* Sets an atomic flag checked at the top of each loop iteration.
|
||||
* The background thread will exit at the next check point.
|
||||
*/
|
||||
void agent_loop_cancel(void);
|
||||
|
||||
/*
|
||||
* Check if the agent loop is currently running.
|
||||
*/
|
||||
gboolean agent_loop_is_running(void);
|
||||
|
||||
/*
|
||||
* Get the current status of the agent loop. Returns the state and
|
||||
* fills the optional output parameters with current status info.
|
||||
*
|
||||
* state_out — current state (may be NULL)
|
||||
* iteration_out — current iteration number (may be NULL)
|
||||
* current_tool_out — name of tool being executed (may be NULL, caller g_free)
|
||||
* last_message_out — most recent assistant text (may be NULL, caller g_free)
|
||||
* error_out — error message if state is ERROR (may be NULL, caller g_free)
|
||||
*/
|
||||
void agent_loop_get_status(agent_loop_state_t *state_out,
|
||||
int *iteration_out,
|
||||
char **current_tool_out,
|
||||
char **last_message_out,
|
||||
char **error_out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_LOOP_H */
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "agent_tools.h"
|
||||
#include "agent_snapshot.h"
|
||||
#include "agent_login.h"
|
||||
#include "agent_tool_catalog.h"
|
||||
#include "tab_manager.h"
|
||||
#include "version.h"
|
||||
|
||||
@@ -117,17 +118,13 @@ static cJSON *rpc_error(int id, int code, const char *message) {
|
||||
}
|
||||
|
||||
/* ── Tool catalog ─────────────────────────────────────────────────── *
|
||||
* Static array of tool definitions. Each has a name, description,
|
||||
* and JSON Schema for input parameters.
|
||||
* Array of tool definitions. Each has a name, description, and JSON
|
||||
* Schema for input parameters. The mcp_tool_def_t struct, the array,
|
||||
* and build_tools_list() are declared in agent_tool_catalog.h so they
|
||||
* can be shared with the embedded agent (agent_llm.c).
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *description;
|
||||
const char *schema_json; /* pre-built JSON schema string */
|
||||
} mcp_tool_def_t;
|
||||
|
||||
static mcp_tool_def_t tool_defs[] = {
|
||||
const mcp_tool_def_t tool_defs[] = {
|
||||
/* Login tools */
|
||||
{"login_status",
|
||||
"Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
|
||||
@@ -162,6 +159,10 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
"Reload the current page (bypassing cache).",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"stop",
|
||||
"Stop loading the current page. Mirrors the toolbar stop button.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"get_url",
|
||||
"Get the current URL of the active tab.",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
@@ -225,9 +226,13 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
|
||||
/* Interaction tools */
|
||||
{"click",
|
||||
"Click an element by ref or CSS selector.",
|
||||
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"click_at",
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"fill",
|
||||
"Clear an input and fill it with a value.",
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
|
||||
@@ -544,13 +549,51 @@ static mcp_tool_def_t tool_defs[] = {
|
||||
{"screenshot_annotated",
|
||||
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
|
||||
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
|
||||
|
||||
/* Filesystem & shell tools (work before login — system-level) */
|
||||
{"fs_read",
|
||||
"Read a file's text contents from the local filesystem. The browser runs in a dedicated qube, so full access is intended. Returns the file content as a string.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute or relative path to the file\"}},\"required\":[\"path\"]}"},
|
||||
|
||||
{"fs_write",
|
||||
"Write text to a file on the local filesystem. Overwrites if the file exists, creates it (and parent directories) if it doesn't. Returns the number of bytes written.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file\"},\"content\":{\"type\":\"string\",\"description\":\"Text content to write\"}},\"required\":[\"path\",\"content\"]}"},
|
||||
|
||||
{"fs_list",
|
||||
"List directory entries (files and subdirectories). Returns each entry's name, type (file/dir/link/other), and size in bytes.",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory\"}},\"required\":[\"path\"]}"},
|
||||
|
||||
{"fs_mkdir",
|
||||
"Create a directory, including parent directories if needed (recursive, like mkdir -p).",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory to create\"}},\"required\":[\"path\"]}"},
|
||||
|
||||
{"fs_delete",
|
||||
"Delete a file or an empty directory. Non-empty directories must be removed with shell_exec (e.g. rm -rf).",
|
||||
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file or empty directory\"}},\"required\":[\"path\"]}"},
|
||||
|
||||
{"shell_exec",
|
||||
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
|
||||
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
|
||||
|
||||
/* Process / per-tab diagnostics (sovereign://processes) */
|
||||
{"processes.list",
|
||||
"List all sovereign_browser processes (main, WebKit renderers with their hosted tabs, WebKit network/gpu/storage, managed Tor/FIPS) with CPU%, RSS/PSS/USS, threads, uptime, I/O, FD count, and service state. CPU% is from /proc utime+stime deltas — the first call returns 0 for all processes (no baseline); poll ~1s for accurate values. Use this to find which WebProcess is hot.",
|
||||
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
|
||||
|
||||
{"processes.tabs",
|
||||
"List all open tabs with their per-tab performance probe (cpu_busy_percent from long-task sum, fps, active timer count, net in-flight, heap used, event listener count, worker count, DOM node count) and WebProcess PID. Sort by cpu_busy_percent to find the tab hogging CPU within a shared WebProcess. Internal sovereign:// tabs have null probes.",
|
||||
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
|
||||
|
||||
{"processes.tab_probe",
|
||||
"Get the full drill-down probe for a single tab: long-task timeline (last 30s), top long-task sources (script URLs + total ms), top active timers (code + interval + count), top network endpoints, heap, workers, DOM nodes. Use after processes.tabs identifies a hot tab to find out WHAT it is doing.",
|
||||
"{\"type\":\"object\",\"properties\":{\"tab_index\":{\"type\":\"integer\",\"description\":\"Tab index from processes.tabs\"}},\"required\":[\"tab_index\"]}"},
|
||||
};
|
||||
|
||||
static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]);
|
||||
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
|
||||
|
||||
/* ── Build tools/list response ────────────────────────────────────── */
|
||||
|
||||
static cJSON *build_tools_list(void) {
|
||||
cJSON *build_tools_list(void) {
|
||||
cJSON *tools = cJSON_CreateArray();
|
||||
for (int i = 0; i < tool_defs_count; i++) {
|
||||
cJSON *tool = cJSON_CreateObject();
|
||||
@@ -693,17 +736,18 @@ static void on_mcp_request(SoupServer *server,
|
||||
if (sid != NULL) {
|
||||
session = session_lookup(sid);
|
||||
if (session == NULL) {
|
||||
/* Unknown or expired session. */
|
||||
cJSON *err_resp = rpc_error(rpc_id, -32000, "Session not found or expired");
|
||||
char *err_str = cJSON_PrintUnformatted(err_resp);
|
||||
cJSON_Delete(err_resp);
|
||||
char *sse = build_sse_response(err_str);
|
||||
free(err_str);
|
||||
soup_server_message_set_status(msg, 404, NULL);
|
||||
soup_server_message_set_response(msg, "text/event-stream",
|
||||
SOUP_MEMORY_TAKE, sse, strlen(sse));
|
||||
cJSON_Delete(request);
|
||||
return;
|
||||
/* Unknown or expired session — create a new one and return
|
||||
* the new session ID in the response header so the client
|
||||
* can update its cached session ID. This handles browser
|
||||
* restarts gracefully without requiring the client to
|
||||
* re-initialize. */
|
||||
session = session_create();
|
||||
SoupMessageHeaders *resp_hdrs =
|
||||
soup_server_message_get_response_headers(msg);
|
||||
soup_message_headers_append(resp_hdrs,
|
||||
"Mcp-Session-Id", session->session_id);
|
||||
g_print("[mcp] Stale session '%s' — created new session '%s'\n",
|
||||
sid, session->session_id);
|
||||
}
|
||||
session_touch(session);
|
||||
} else {
|
||||
|
||||
@@ -14,10 +14,50 @@
|
||||
#include <libsoup/soup.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* Forward declarations from agent_tools.c */
|
||||
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
|
||||
|
||||
/* ── Discovery file ───────────────────────────────────────────────── *
|
||||
* When the configured port (default 17777) is already in use by another
|
||||
* browser instance, we fall back to an OS-assigned port (bind to 0). So
|
||||
* that external clients and browser.sh can find the actual port, we write
|
||||
* a per-instance discovery file at /tmp/sovereign_browser_<pid>.port
|
||||
* containing the PID and port. browser.sh globs these files to enumerate
|
||||
* running instances. The file is removed on clean shutdown. */
|
||||
static char g_discovery_path[128] = {0};
|
||||
|
||||
static void build_discovery_path(char *out, size_t out_sz) {
|
||||
snprintf(out, out_sz, "/tmp/sovereign_browser_%d.port", (int)getpid());
|
||||
}
|
||||
|
||||
static void write_discovery_file(int port) {
|
||||
char path[128];
|
||||
FILE *fp;
|
||||
build_discovery_path(path, sizeof(path));
|
||||
fp = fopen(path, "w");
|
||||
if (fp == NULL) {
|
||||
g_printerr("[agent] Failed to write discovery file %s\n", path);
|
||||
return;
|
||||
}
|
||||
/* Format: "PID PORT\n" — easy to parse from shell with `read`. */
|
||||
fprintf(fp, "%d %d\n", (int)getpid(), port);
|
||||
fclose(fp);
|
||||
snprintf(g_discovery_path, sizeof(g_discovery_path), "%s", path);
|
||||
g_print("[agent] Discovery file: %s (pid=%d port=%d)\n",
|
||||
path, (int)getpid(), port);
|
||||
}
|
||||
|
||||
static void remove_discovery_file(void) {
|
||||
if (g_discovery_path[0] != '\0') {
|
||||
unlink(g_discovery_path);
|
||||
g_discovery_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Static state ─────────────────────────────────────────────────── */
|
||||
|
||||
static SoupServer *g_server = NULL;
|
||||
@@ -210,8 +250,20 @@ int agent_server_start(int port) {
|
||||
/* Add MCP handler at /mcp. */
|
||||
agent_mcp_register(g_server);
|
||||
|
||||
/* Listen on the specified port (0 = auto-assign). */
|
||||
/* Listen on the specified port. If it's already in use (another browser
|
||||
* instance is running), fall back to port 0 so the OS picks a free port.
|
||||
* The actual bound port is written to a discovery file so browser.sh and
|
||||
* external MCP clients can find it. */
|
||||
soup_server_listen_local(g_server, port, 0, &error);
|
||||
if (error != NULL) {
|
||||
if (port != 0) {
|
||||
g_printerr("[agent] Port %d in use (%s) — falling back to OS auto-assign.\n",
|
||||
port, error->message);
|
||||
g_error_free(error);
|
||||
error = NULL;
|
||||
soup_server_listen_local(g_server, 0, 0, &error);
|
||||
}
|
||||
}
|
||||
if (error != NULL) {
|
||||
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
|
||||
g_error_free(error);
|
||||
@@ -233,6 +285,11 @@ int agent_server_start(int port) {
|
||||
g_running = TRUE;
|
||||
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
|
||||
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
|
||||
if (g_port != port) {
|
||||
g_print("[agent] Note: using auto-assigned port %d (configured port %d was in use).\n",
|
||||
g_port, port);
|
||||
}
|
||||
write_discovery_file(g_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -258,6 +315,7 @@ void agent_server_stop(void) {
|
||||
|
||||
g_running = FALSE;
|
||||
g_port = 0;
|
||||
remove_discovery_file();
|
||||
g_print("[agent] Server stopped\n");
|
||||
}
|
||||
|
||||
|
||||
833
src/agent_skills.c
Normal file
833
src/agent_skills.c
Normal file
@@ -0,0 +1,833 @@
|
||||
/*
|
||||
* agent_skills.c — Nostr kind 31123 skill management for sovereign_browser
|
||||
*
|
||||
* Implements fetch, selection, system-prompt building, publish, and delete
|
||||
* for kind 31123 skill events. Skills are PUBLIC events (no NIP-44
|
||||
* encryption) authored by any user. sovereign_browser fetches them from
|
||||
* the local SQLite cache (populated by the relay fetch thread on startup)
|
||||
* and lets users select skills whose templates are concatenated into the
|
||||
* system prompt.
|
||||
*
|
||||
* Reuses the event signing + relay publishing patterns from
|
||||
* settings_sync.c, bookmarks.c, and agent_conversations.c.
|
||||
*
|
||||
* See plans/cross-project-agent-sync.md (Phase C/D) for the full design.
|
||||
*/
|
||||
|
||||
#include "agent_skills.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Global state ───────────────────────────────────────────────────── */
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static int g_have_signer = 0;
|
||||
|
||||
/* ── Init ───────────────────────────────────────────────────────────── */
|
||||
|
||||
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
agent_skills_init(signer, pubkey_hex);
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse bootstrap relays from settings into an array.
|
||||
* Returns the count. Fills urls_out (pointers into relay_buf).
|
||||
* Caller must g_free(relay_buf) after use. (Same pattern as
|
||||
* settings_sync.c, bookmarks.c, agent_conversations.c.) */
|
||||
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
*relay_buf = g_strdup(s->bootstrap_relays);
|
||||
int count = 0;
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && count < max) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
urls_out[count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Get the value of the first tag with the given name. Returns a pointer
|
||||
* into the event's tags (or NULL). Not owned by caller. */
|
||||
static const char *event_tag_value(const cJSON *event, const char *name) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return NULL;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
|
||||
cJSON_IsString(t1)) {
|
||||
return t1->valuestring;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Check whether an event has a tag [name, value]. Returns 1/0. */
|
||||
static int event_has_tag(const cJSON *event, const char *name,
|
||||
const char *value) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
|
||||
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Collect all values of tags with the given name into a newly allocated
|
||||
* cJSON array of strings. Caller must cJSON_Delete(). */
|
||||
static cJSON *event_tag_values(const cJSON *event, const char *name) {
|
||||
cJSON *result = cJSON_CreateArray();
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return result;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
|
||||
cJSON_IsString(t1)) {
|
||||
cJSON_AddItemToArray(result, cJSON_CreateString(t1->valuestring));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Fetch / list ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse a single kind 31123 event into an agent_skill_t struct.
|
||||
* Returns 0 on success, -1 on error. On success, the skill's owned
|
||||
* fields (content, requires_tools) are allocated and must be freed
|
||||
* via agent_skills_free_list(). */
|
||||
static int parse_skill_event(const cJSON *event, agent_skill_t *out) {
|
||||
if (event == NULL || out == NULL) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* d-tag (unique identifier) */
|
||||
const char *d = event_tag_value(event, "d");
|
||||
if (d == NULL || d[0] == '\0') return -1;
|
||||
snprintf(out->d_tag, sizeof(out->d_tag), "%s", d);
|
||||
|
||||
/* name tag */
|
||||
const char *name = event_tag_value(event, "name");
|
||||
if (name) {
|
||||
snprintf(out->name, sizeof(out->name), "%s", name);
|
||||
} else {
|
||||
/* Fall back to the d-tag if no name tag. */
|
||||
snprintf(out->name, sizeof(out->name), "%s", d);
|
||||
}
|
||||
|
||||
/* description tag */
|
||||
const char *desc = event_tag_value(event, "description");
|
||||
if (desc) {
|
||||
snprintf(out->description, sizeof(out->description), "%s", desc);
|
||||
}
|
||||
|
||||
/* author pubkey */
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
if (cJSON_IsString(pk)) {
|
||||
snprintf(out->pubkey, sizeof(out->pubkey), "%s", pk->valuestring);
|
||||
}
|
||||
|
||||
/* content (system prompt template) */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (cJSON_IsString(content)) {
|
||||
out->content = g_strdup(content->valuestring);
|
||||
} else {
|
||||
out->content = g_strdup("");
|
||||
}
|
||||
|
||||
/* requires_tool tags */
|
||||
cJSON *tools = event_tag_values(event, "requires_tool");
|
||||
if (tools != NULL) {
|
||||
int n = cJSON_GetArraySize(tools);
|
||||
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
|
||||
out->tool_count = n;
|
||||
if (n > 0) {
|
||||
out->requires_tools = g_new0(char *, n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *t = cJSON_GetArrayItem(tools, i);
|
||||
if (cJSON_IsString(t)) {
|
||||
out->requires_tools[i] = g_strdup(t->valuestring);
|
||||
} else {
|
||||
out->requires_tools[i] = g_strdup("");
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(tools);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_skills_free_list(agent_skill_t *skills, int count) {
|
||||
if (skills == NULL) return;
|
||||
for (int i = 0; i < count; i++) {
|
||||
g_free(skills[i].content);
|
||||
if (skills[i].requires_tools) {
|
||||
for (int j = 0; j < skills[i].tool_count; j++) {
|
||||
g_free(skills[i].requires_tools[j]);
|
||||
}
|
||||
g_free(skills[i].requires_tools);
|
||||
}
|
||||
}
|
||||
g_free(skills);
|
||||
}
|
||||
|
||||
/* Fetch and parse all kind 31123 events into an array of agent_skill_t.
|
||||
* Returns the array (caller must agent_skills_free_list()) and sets
|
||||
* *count_out. Returns NULL on error or if no skills exist. */
|
||||
static agent_skill_t *fetch_skill_structs(int *count_out) {
|
||||
if (count_out == NULL) return NULL;
|
||||
*count_out = 0;
|
||||
|
||||
cJSON *events = db_get_events_by_kind(AGENT_SKILL_KIND, 500);
|
||||
if (events == NULL) return NULL;
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
if (n <= 0) {
|
||||
cJSON_Delete(events);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Deduplicate by d-tag: keep the newest (events are newest-first
|
||||
* from db_get_events_by_kind). */
|
||||
agent_skill_t *skills = g_new0(agent_skill_t, n);
|
||||
int count = 0;
|
||||
cJSON *seen_d = cJSON_CreateArray();
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
const char *d = event_tag_value(ev, "d");
|
||||
if (d == NULL || d[0] == '\0') continue;
|
||||
|
||||
/* Skip if we've already seen this d-tag. */
|
||||
int seen = 0;
|
||||
cJSON *s;
|
||||
cJSON_ArrayForEach(s, seen_d) {
|
||||
if (cJSON_IsString(s) && strcmp(s->valuestring, d) == 0) {
|
||||
seen = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (seen) continue;
|
||||
cJSON_AddItemToArray(seen_d, cJSON_CreateString(d));
|
||||
|
||||
if (parse_skill_event(ev, &skills[count]) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(seen_d);
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (count == 0) {
|
||||
g_free(skills);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
*count_out = count;
|
||||
return skills;
|
||||
}
|
||||
|
||||
cJSON *agent_skills_fetch(void) {
|
||||
cJSON *result = cJSON_CreateArray();
|
||||
|
||||
int count = 0;
|
||||
agent_skill_t *skills = fetch_skill_structs(&count);
|
||||
if (skills == NULL) return result;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(obj, "d", skills[i].d_tag);
|
||||
cJSON_AddStringToObject(obj, "name", skills[i].name);
|
||||
cJSON_AddStringToObject(obj, "description", skills[i].description);
|
||||
cJSON_AddStringToObject(obj, "pubkey", skills[i].pubkey);
|
||||
cJSON_AddStringToObject(obj, "content", skills[i].content);
|
||||
|
||||
/* requires_tools array */
|
||||
cJSON *tools = cJSON_CreateArray();
|
||||
for (int j = 0; j < skills[i].tool_count; j++) {
|
||||
cJSON_AddItemToArray(tools,
|
||||
cJSON_CreateString(skills[i].requires_tools[j]));
|
||||
}
|
||||
cJSON_AddItemToObject(obj, "requires_tools", tools);
|
||||
|
||||
cJSON_AddItemToArray(result, obj);
|
||||
}
|
||||
|
||||
agent_skills_free_list(skills, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Sovereign Browser Skill (default, from local settings) ─────────── */
|
||||
|
||||
cJSON *agent_skills_get_default(void) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
/* d is empty — this skill is unsaved (not published to Nostr). */
|
||||
cJSON_AddStringToObject(obj, "d", "");
|
||||
cJSON_AddStringToObject(obj, "name",
|
||||
s->agent_skill_name[0] ? s->agent_skill_name
|
||||
: SETTINGS_AGENT_SKILL_NAME_DEFAULT);
|
||||
cJSON_AddStringToObject(obj, "description",
|
||||
s->agent_skill_description[0] ? s->agent_skill_description
|
||||
: SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
|
||||
cJSON_AddStringToObject(obj, "content",
|
||||
s->agent_skill_template[0] ? s->agent_skill_template
|
||||
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
|
||||
cJSON_AddStringToObject(obj, "pubkey", "");
|
||||
cJSON_AddBoolToObject(obj, "unsaved", 1);
|
||||
|
||||
/* Parse requires_tools (comma-separated) into a JSON array. */
|
||||
cJSON *tools = cJSON_CreateArray();
|
||||
const char *tools_str = s->agent_skill_requires_tools[0]
|
||||
? s->agent_skill_requires_tools
|
||||
: SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT;
|
||||
char *buf = g_strdup(tools_str);
|
||||
char *saveptr = NULL;
|
||||
char *tok = strtok_r(buf, ",", &saveptr);
|
||||
while (tok != NULL) {
|
||||
/* Trim leading whitespace. */
|
||||
while (*tok == ' ' || *tok == '\t') tok++;
|
||||
if (tok[0] != '\0') {
|
||||
cJSON_AddItemToArray(tools, cJSON_CreateString(tok));
|
||||
}
|
||||
tok = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
g_free(buf);
|
||||
cJSON_AddItemToObject(obj, "requires_tools", tools);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/* ── Selection (local settings) ────────────────────────────────────── */
|
||||
|
||||
cJSON *agent_skills_get_selected(void) {
|
||||
cJSON *result = cJSON_CreateArray();
|
||||
char *json_str = db_kv_get_copy(AGENT_SKILLS_SELECTED_KEY);
|
||||
if (json_str == NULL || json_str[0] == '\0') {
|
||||
g_free(json_str);
|
||||
return result;
|
||||
}
|
||||
|
||||
cJSON *parsed = cJSON_Parse(json_str);
|
||||
g_free(json_str);
|
||||
if (cJSON_IsArray(parsed)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, parsed) {
|
||||
if (cJSON_IsString(item)) {
|
||||
cJSON_AddItemToArray(result, cJSON_CreateString(item->valuestring));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed) cJSON_Delete(parsed);
|
||||
return result;
|
||||
}
|
||||
|
||||
int agent_skills_set_selected(const char *d_tags_json) {
|
||||
if (d_tags_json == NULL) d_tags_json = "[]";
|
||||
return db_kv_set(AGENT_SKILLS_SELECTED_KEY, d_tags_json);
|
||||
}
|
||||
|
||||
cJSON *agent_skills_toggle_selected(const char *d_tag) {
|
||||
if (d_tag == NULL || d_tag[0] == '\0') return NULL;
|
||||
|
||||
cJSON *selected = agent_skills_get_selected();
|
||||
|
||||
/* Check if already selected. */
|
||||
int found = -1;
|
||||
int n = cJSON_GetArraySize(selected);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(selected, i);
|
||||
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
|
||||
found = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found >= 0) {
|
||||
/* Remove it. */
|
||||
cJSON_DeleteItemFromArray(selected, found);
|
||||
} else {
|
||||
/* Add it (at the end to preserve selection order). */
|
||||
cJSON_AddItemToArray(selected, cJSON_CreateString(d_tag));
|
||||
}
|
||||
|
||||
/* Save back to db_kv. */
|
||||
char *json = cJSON_PrintUnformatted(selected);
|
||||
if (json) {
|
||||
agent_skills_set_selected(json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/* ── Build system prompt ───────────────────────────────────────────── */
|
||||
|
||||
char *agent_skills_build_system_prompt(void) {
|
||||
/* Always start with the Sovereign Browser Skill template (the
|
||||
* default system prompt from local settings). This is the base
|
||||
* prompt; selected Nostr skills are appended after it. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
const char *base = s->agent_skill_template[0]
|
||||
? s->agent_skill_template
|
||||
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT;
|
||||
|
||||
GString *combined = g_string_new(base);
|
||||
|
||||
/* Fetch selected skills and append their templates. */
|
||||
cJSON *selected = agent_skills_get_selected();
|
||||
if (selected != NULL) {
|
||||
int n = cJSON_GetArraySize(selected);
|
||||
if (n > 0) {
|
||||
int skill_count = 0;
|
||||
agent_skill_t *skills = fetch_skill_structs(&skill_count);
|
||||
if (skills != NULL) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(selected, i);
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
const char *d = item->valuestring;
|
||||
|
||||
agent_skill_t *skill = NULL;
|
||||
for (int j = 0; j < skill_count; j++) {
|
||||
if (strcmp(skills[j].d_tag, d) == 0) {
|
||||
skill = &skills[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skill == NULL || skill->content == NULL ||
|
||||
skill->content[0] == '\0') continue;
|
||||
|
||||
g_string_append(combined, "\n\n---\n\n");
|
||||
g_string_append(combined, skill->content);
|
||||
}
|
||||
agent_skills_free_list(skills, skill_count);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(selected);
|
||||
}
|
||||
|
||||
return g_string_free(combined, FALSE);
|
||||
}
|
||||
|
||||
/* ── Publish ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* Derive a d-tag from a skill name: lowercase, replace spaces/separators
|
||||
* with hyphens, strip non-alphanumeric. Returns a newly allocated string
|
||||
* (caller must g_free). */
|
||||
static char *derive_d_tag(const char *name) {
|
||||
if (name == NULL || name[0] == '\0') {
|
||||
/* Fall back to a timestamp-based id. */
|
||||
return g_strdup_printf("skill-%ld", (long)time(NULL));
|
||||
}
|
||||
|
||||
/* Build a slug. */
|
||||
GString *slug = g_string_new(NULL);
|
||||
for (const char *p = name; *p; p++) {
|
||||
char c = *p;
|
||||
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
|
||||
g_string_append_c(slug, c);
|
||||
} else if (c >= 'A' && c <= 'Z') {
|
||||
g_string_append_c(slug, c - 'A' + 'a');
|
||||
} else if (c == ' ' || c == '_' || c == '-' || c == '.') {
|
||||
g_string_append_c(slug, '-');
|
||||
}
|
||||
/* Skip other characters. */
|
||||
}
|
||||
|
||||
/* Collapse consecutive hyphens and trim leading/trailing. */
|
||||
GString *clean = g_string_new(NULL);
|
||||
int prev_hyphen = 1; /* treat start as if previous was hyphen */
|
||||
for (gsize i = 0; i < slug->len; i++) {
|
||||
char c = slug->str[i];
|
||||
if (c == '-') {
|
||||
if (!prev_hyphen) {
|
||||
g_string_append_c(clean, '-');
|
||||
prev_hyphen = 1;
|
||||
}
|
||||
} else {
|
||||
g_string_append_c(clean, c);
|
||||
prev_hyphen = 0;
|
||||
}
|
||||
}
|
||||
/* Trim trailing hyphen. */
|
||||
if (clean->len > 0 && clean->str[clean->len - 1] == '-') {
|
||||
g_string_truncate(clean, clean->len - 1);
|
||||
}
|
||||
g_string_free(slug, TRUE);
|
||||
|
||||
if (clean->len == 0) {
|
||||
g_string_free(clean, TRUE);
|
||||
return g_strdup_printf("skill-%ld", (long)time(NULL));
|
||||
}
|
||||
|
||||
/* Append a short timestamp suffix to ensure uniqueness. */
|
||||
char *result = g_strdup_printf("%s-%ld", clean->str, (long)time(NULL) % 100000);
|
||||
g_string_free(clean, TRUE);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *agent_skills_publish(const char *name,
|
||||
const char *description,
|
||||
const char *content,
|
||||
const char *requires_tools_json) {
|
||||
if (!g_have_signer) {
|
||||
g_printerr("[agent_skills] No signer, cannot publish\n");
|
||||
return NULL;
|
||||
}
|
||||
if (content == NULL || content[0] == '\0') {
|
||||
g_printerr("[agent_skills] Content is required\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Derive the d-tag from the name. */
|
||||
char *d_tag = derive_d_tag(name);
|
||||
|
||||
/* Build tags:
|
||||
* [["d", d_tag],
|
||||
* ["name", name],
|
||||
* ["description", desc],
|
||||
* ["client", "sovereign_browser"],
|
||||
* ["requires_tool", tool1], ...] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
cJSON *d_tag_arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToArray(tags, d_tag_arr);
|
||||
|
||||
if (name && name[0]) {
|
||||
cJSON *name_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
|
||||
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
|
||||
cJSON_AddItemToArray(tags, name_tag);
|
||||
}
|
||||
|
||||
if (description && description[0]) {
|
||||
cJSON *desc_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
|
||||
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
|
||||
cJSON_AddItemToArray(tags, desc_tag);
|
||||
}
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
/* Parse requires_tools_json and add a requires_tool tag for each. */
|
||||
if (requires_tools_json && requires_tools_json[0]) {
|
||||
cJSON *tools = cJSON_Parse(requires_tools_json);
|
||||
if (cJSON_IsArray(tools)) {
|
||||
int n = cJSON_GetArraySize(tools);
|
||||
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *t = cJSON_GetArrayItem(tools, i);
|
||||
if (cJSON_IsString(t) && t->valuestring[0]) {
|
||||
cJSON *rt_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
|
||||
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
|
||||
cJSON_AddItemToArray(tags, rt_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tools) cJSON_Delete(tools);
|
||||
}
|
||||
|
||||
/* Create and sign the event. Skills are PUBLIC — no encryption.
|
||||
* The content is the raw system prompt template. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
|
||||
cJSON_Delete(tags);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[agent_skills] Failed to create/sign event\n");
|
||||
g_free(d_tag);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_skills] Published skill '%s' (d:%s) to %d/%d relays\n",
|
||||
name ? name : "(unnamed)", d_tag, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[agent_skills] No relays configured, skill stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
return d_tag;
|
||||
}
|
||||
|
||||
/* ── Delete ────────────────────────────────────────────────────────── */
|
||||
|
||||
int agent_skills_delete(const char *d_tag) {
|
||||
if (d_tag == NULL || d_tag[0] == '\0') return -1;
|
||||
if (g_pubkey[0] == '\0') return -1;
|
||||
|
||||
/* Find the kind 31123 event with this d-tag authored by the user. */
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
|
||||
if (events == NULL) return -1;
|
||||
|
||||
char *event_id = NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag)) {
|
||||
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
|
||||
if (cJSON_IsString(id_item)) {
|
||||
event_id = g_strdup(id_item->valuestring);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (event_id == NULL) {
|
||||
g_printerr("[agent_skills] Skill '%s' not found for delete\n", d_tag);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Publish a kind 5 deletion event referencing the skill event. */
|
||||
if (g_have_signer) {
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
cJSON *e_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
|
||||
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
|
||||
cJSON_AddItemToArray(tags, e_tag);
|
||||
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
5, "Deleted skill", tags, g_signer, time(NULL));
|
||||
cJSON_Delete(tags);
|
||||
|
||||
if (event) {
|
||||
db_store_event(event);
|
||||
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results =
|
||||
synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_skills] Published deletion for '%s' to %d/%d relays\n",
|
||||
d_tag, success_count, relay_count);
|
||||
}
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove from local SQLite cache. */
|
||||
db_delete_event(event_id);
|
||||
g_free(event_id);
|
||||
|
||||
/* Also remove from the selected list if present. */
|
||||
cJSON *selected = agent_skills_get_selected();
|
||||
if (selected) {
|
||||
int sn = cJSON_GetArraySize(selected);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(selected, i);
|
||||
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
|
||||
cJSON_DeleteItemFromArray(selected, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(selected);
|
||||
if (json) {
|
||||
agent_skills_set_selected(json);
|
||||
free(json);
|
||||
}
|
||||
cJSON_Delete(selected);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Update (re-publish an existing skill) ──────────────────────────── */
|
||||
|
||||
int agent_skills_update(const char *d_tag,
|
||||
const char *name,
|
||||
const char *description,
|
||||
const char *content,
|
||||
const char *requires_tools_json) {
|
||||
if (d_tag == NULL || d_tag[0] == '\0') return -1;
|
||||
if (content == NULL || content[0] == '\0') return -1;
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') {
|
||||
g_printerr("[agent_skills] No signer, cannot update\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Find the existing kind 31123 event with this d-tag authored by
|
||||
* the user. We reuse the existing d-tag (addressable event) so the
|
||||
* update replaces the prior version. */
|
||||
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
|
||||
if (events == NULL) {
|
||||
g_printerr("[agent_skills] No skill events found for update\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *found = NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_tag(ev, "d", d_tag)) {
|
||||
found = cJSON_Duplicate(ev, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (found == NULL) {
|
||||
g_printerr("[agent_skills] Skill '%s' not found for update\n", d_tag);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* If name/description not provided, keep the existing values. */
|
||||
char name_buf[128];
|
||||
char desc_buf[512];
|
||||
if (name == NULL || name[0] == '\0') {
|
||||
const char *old = event_tag_value(found, "name");
|
||||
snprintf(name_buf, sizeof(name_buf), "%s", old ? old : d_tag);
|
||||
name = name_buf;
|
||||
}
|
||||
if (description == NULL) {
|
||||
const char *old = event_tag_value(found, "description");
|
||||
snprintf(desc_buf, sizeof(desc_buf), "%s", old ? old : "");
|
||||
description = desc_buf;
|
||||
}
|
||||
|
||||
/* Build the new tags: same d-tag, new name/description, client tag,
|
||||
* and requires_tool tags. */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
cJSON *d_tag_arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToArray(tags, d_tag_arr);
|
||||
|
||||
if (name && name[0]) {
|
||||
cJSON *name_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
|
||||
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
|
||||
cJSON_AddItemToArray(tags, name_tag);
|
||||
}
|
||||
|
||||
if (description && description[0]) {
|
||||
cJSON *desc_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
|
||||
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
|
||||
cJSON_AddItemToArray(tags, desc_tag);
|
||||
}
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
if (requires_tools_json && requires_tools_json[0]) {
|
||||
cJSON *tools = cJSON_Parse(requires_tools_json);
|
||||
if (cJSON_IsArray(tools)) {
|
||||
int tn = cJSON_GetArraySize(tools);
|
||||
if (tn > AGENT_SKILL_MAX_TOOLS) tn = AGENT_SKILL_MAX_TOOLS;
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON *t = cJSON_GetArrayItem(tools, i);
|
||||
if (cJSON_IsString(t) && t->valuestring[0]) {
|
||||
cJSON *rt_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
|
||||
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
|
||||
cJSON_AddItemToArray(tags, rt_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tools) cJSON_Delete(tools);
|
||||
}
|
||||
|
||||
/* Create and sign the new event (same d-tag → replaces the old). */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(found);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[agent_skills] Failed to create/sign update event\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Store in SQLite (replaces the old event by id; the d-tag is the
|
||||
* same so fetch_skill_structs dedup keeps the newest). */
|
||||
db_store_event(event);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[agent_skills] Updated skill '%s' to %d/%d relays\n",
|
||||
d_tag, success_count, relay_count);
|
||||
} else {
|
||||
g_print("[agent_skills] No relays configured, update stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
return 0;
|
||||
}
|
||||
204
src/agent_skills.h
Normal file
204
src/agent_skills.h
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* agent_skills.h — Nostr kind 31123 skill management for sovereign_browser
|
||||
*
|
||||
* Skills are PUBLIC Nostr events (kind 31123) that define system prompt
|
||||
* templates, LLM parameters, and tool requirements. They are addressable
|
||||
* events (NIP-51 list kind variant) with a "d" tag (unique identifier),
|
||||
* a "name" tag, and content that is the system prompt template.
|
||||
*
|
||||
* sovereign_browser has a unique advantage over ~/lt/client/www/ai.html:
|
||||
* it has browser tools + filesystem/shell tools, so skills with
|
||||
* "requires_tool" tags are fully functional here.
|
||||
*
|
||||
* Users select multiple skills via checkboxes; their templates are
|
||||
* concatenated into the system prompt in selection order. The agent loop
|
||||
* calls agent_skills_build_system_prompt() to get the combined prompt.
|
||||
*
|
||||
* See plans/cross-project-agent-sync.md (Phase C/D) and
|
||||
* ~/lt/client/plans/ai-skills-integration.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_SKILLS_H
|
||||
#define AGENT_SKILLS_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* cJSON is in the vendored nostr_core_lib */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* nostr_signer_t is needed for init */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The Nostr kind for skills (NIP-51 list kind variant, addressable). */
|
||||
#define AGENT_SKILL_KIND 31123
|
||||
|
||||
/* db_kv key for the selected skill d-tags (stored as a JSON array string). */
|
||||
#define AGENT_SKILLS_SELECTED_KEY "agent.selected_skills"
|
||||
|
||||
/* Maximum number of requires_tool tags per skill. */
|
||||
#define AGENT_SKILL_MAX_TOOLS 32
|
||||
|
||||
/* ── Skill struct ──────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char d_tag[128]; /* unique identifier */
|
||||
char name[128]; /* skill name */
|
||||
char description[512]; /* short description */
|
||||
char pubkey[65]; /* author pubkey (hex) */
|
||||
char *content; /* system prompt template (owned, g_free) */
|
||||
char **requires_tools; /* array of tool name strings (owned, g_free) */
|
||||
int tool_count;
|
||||
} agent_skill_t;
|
||||
|
||||
/* ── Lifecycle ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Initialize the skills module. Stores the signer + pubkey references
|
||||
* for publish/delete operations.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
|
||||
*
|
||||
* Call after login.
|
||||
*/
|
||||
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Update the signer reference (e.g. after switching identity).
|
||||
*/
|
||||
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
|
||||
/* ── Fetch / list ──────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Fetch kind 31123 skill events from the local SQLite cache and parse
|
||||
* them into skill structs. Skills are public events from any author.
|
||||
*
|
||||
* Returns a newly allocated cJSON array of skill summary objects:
|
||||
* [{"d":"...", "name":"...", "description":"...",
|
||||
* "requires_tools":["tool1","tool2"],
|
||||
* "content":"...", "pubkey":"..."}, ...]
|
||||
* Returns an empty array on error or if no skills exist.
|
||||
* Caller must cJSON_Delete() the result.
|
||||
*/
|
||||
cJSON *agent_skills_fetch(void);
|
||||
|
||||
/*
|
||||
* Get the Sovereign Browser Skill — the default skill stored in local
|
||||
* settings (not from Nostr). Returns a newly allocated cJSON object:
|
||||
* {"d":"", "name":"...", "description":"...", "content":"...",
|
||||
* "requires_tools":["tool1","tool2"],
|
||||
* "unsaved":true, "pubkey":""}
|
||||
* Caller must cJSON_Delete() the result.
|
||||
*/
|
||||
cJSON *agent_skills_get_default(void);
|
||||
|
||||
/*
|
||||
* Free an array of agent_skill_t structs (and each skill's owned fields).
|
||||
* skills — the array (may be NULL)
|
||||
* count — number of entries in the array
|
||||
*/
|
||||
void agent_skills_free_list(agent_skill_t *skills, int count);
|
||||
|
||||
/* ── Selection (local settings) ────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Get the list of currently selected skill d-tags from local settings.
|
||||
*
|
||||
* Returns a newly allocated cJSON array of d-tag strings:
|
||||
* ["d-tag-1", "d-tag-2", ...]
|
||||
* Returns an empty array if none selected. Caller must cJSON_Delete().
|
||||
*/
|
||||
cJSON *agent_skills_get_selected(void);
|
||||
|
||||
/*
|
||||
* Save the selected skill d-tags to local settings (db_kv).
|
||||
*
|
||||
* d_tags_json — a JSON array string of d-tags, e.g. "[\"a\",\"b\"]"
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_skills_set_selected(const char *d_tags_json);
|
||||
|
||||
/*
|
||||
* Toggle selection of a skill: add the d-tag if not selected, remove it
|
||||
* if already selected. Saves the updated selection to local settings.
|
||||
*
|
||||
* d_tag — the skill's d-tag value
|
||||
*
|
||||
* Returns a newly allocated cJSON array of the updated selection
|
||||
* (caller must cJSON_Delete()), or NULL on error.
|
||||
*/
|
||||
cJSON *agent_skills_toggle_selected(const char *d_tag);
|
||||
|
||||
/* ── Build system prompt ───────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Build the system prompt by concatenating the selected skills'
|
||||
* templates in selection order. Templates are separated by
|
||||
* "\n\n---\n\n".
|
||||
*
|
||||
* Returns a newly allocated string (caller must g_free), or NULL if no
|
||||
* skills are selected (caller should fall back to the default prompt).
|
||||
*/
|
||||
char *agent_skills_build_system_prompt(void);
|
||||
|
||||
/* ── Publish / delete ──────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Create and publish a new kind 31123 skill event. Signs with the
|
||||
* user's key, publishes to bootstrap relays, and stores in SQLite.
|
||||
* Skills are PUBLIC events — no NIP-44 encryption.
|
||||
*
|
||||
* name — skill name (also used to derive the d-tag)
|
||||
* description — short description (may be NULL or "")
|
||||
* content — the system prompt template (required)
|
||||
* requires_tools_json — JSON array string of tool names, e.g.
|
||||
* "[\"browser\",\"fs\"]" (may be NULL or "[]")
|
||||
*
|
||||
* Returns a newly allocated string with the d-tag of the new skill
|
||||
* (caller must g_free), or NULL on error.
|
||||
*/
|
||||
char *agent_skills_publish(const char *name,
|
||||
const char *description,
|
||||
const char *content,
|
||||
const char *requires_tools_json);
|
||||
|
||||
/*
|
||||
* Publish a kind 5 deletion event for a skill authored by the user,
|
||||
* and remove it from the local SQLite cache.
|
||||
*
|
||||
* d_tag — the skill's d-tag value
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_skills_delete(const char *d_tag);
|
||||
|
||||
/*
|
||||
* Update an existing kind 31123 skill event with new content. Fetches
|
||||
* the existing event by d-tag (authored by the user), updates its
|
||||
* name/description/content/requires_tools, re-signs, and re-publishes.
|
||||
*
|
||||
* d_tag — the skill's d-tag value (identifies the skill)
|
||||
* name — new skill name (may be NULL to keep existing)
|
||||
* description — new description (may be NULL to keep existing)
|
||||
* content — new system prompt template (required)
|
||||
* requires_tools_json — JSON array string of tool names (may be NULL)
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int agent_skills_update(const char *d_tag,
|
||||
const char *name,
|
||||
const char *description,
|
||||
const char *content,
|
||||
const char *requires_tools_json);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_SKILLS_H */
|
||||
@@ -337,6 +337,7 @@ const char *AGENT_SNAPSHOT_JS =
|
||||
" refCount++;\n"
|
||||
" var ref = 'e' + refCount;\n"
|
||||
" var selector = getSelector(el);\n"
|
||||
" var rect = el.getBoundingClientRect();\n"
|
||||
" refMap[ref] = {\n"
|
||||
" role: role,\n"
|
||||
" name: name,\n"
|
||||
@@ -344,7 +345,13 @@ const char *AGENT_SNAPSHOT_JS =
|
||||
" tag: el.tagName.toLowerCase(),\n"
|
||||
" href: el.href || '',\n"
|
||||
" type: el.type || '',\n"
|
||||
" value: el.value || ''\n"
|
||||
" value: el.value || '',\n"
|
||||
" bbox: {\n"
|
||||
" x: rect.x,\n"
|
||||
" y: rect.y,\n"
|
||||
" width: rect.width,\n"
|
||||
" height: rect.height\n"
|
||||
" }\n"
|
||||
" };\n"
|
||||
" line += ' [ref=' + ref + ']';\n"
|
||||
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"
|
||||
|
||||
46
src/agent_tool_catalog.h
Normal file
46
src/agent_tool_catalog.h
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* agent_tool_catalog.h — shared tool catalog definitions
|
||||
*
|
||||
* Exposes the tool_defs[] array, its count, and build_tools_list()
|
||||
* so that both the MCP server (agent_mcp.c) and the embedded agent
|
||||
* (agent_llm.c, future) can access the same tool catalog.
|
||||
*
|
||||
* The definitions live in agent_mcp.c; this header just makes them
|
||||
* non-static and declares them here.
|
||||
*/
|
||||
|
||||
#ifndef AGENT_TOOL_CATALOG_H
|
||||
#define AGENT_TOOL_CATALOG_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Tool definition struct ────────────────────────────────────────── *
|
||||
* Each entry has a name, human-readable description, and a pre-built
|
||||
* JSON Schema string for the tool's input parameters.
|
||||
*/
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *description;
|
||||
const char *schema_json; /* pre-built JSON schema string */
|
||||
} mcp_tool_def_t;
|
||||
|
||||
/* The shared tool catalog array and its length. */
|
||||
extern const mcp_tool_def_t tool_defs[];
|
||||
extern const int tool_defs_count;
|
||||
|
||||
/*
|
||||
* Build a cJSON array of tool definitions in the MCP tools/list format:
|
||||
* [{"name":"...","description":"...","inputSchema":{...}}, ...]
|
||||
* The caller frees the returned cJSON*.
|
||||
*/
|
||||
cJSON *build_tools_list(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* AGENT_TOOL_CATALOG_H */
|
||||
File diff suppressed because it is too large
Load Diff
1103
src/bookmarks.c
Normal file
1103
src/bookmarks.c
Normal file
File diff suppressed because it is too large
Load Diff
155
src/bookmarks.h
Normal file
155
src/bookmarks.h
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
|
||||
*
|
||||
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
|
||||
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
|
||||
*
|
||||
* Privacy design:
|
||||
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
|
||||
* 64-hex-char identifier. Relays learn nothing about folder names or
|
||||
* structure (not even the folder count). Determinism preserves NIP-33
|
||||
* replaceability: re-publishing the same path replaces the prior event.
|
||||
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
|
||||
* both use a random IV/nonce per call, which would break replaceability.
|
||||
* - The real path and the bookmark list live inside the NIP-44 encrypted
|
||||
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
|
||||
* - The HMAC key is derived from the user's Nostr privkey:
|
||||
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
* It is per-user, never published, and automatically available on every
|
||||
* device the user logs in on.
|
||||
*
|
||||
* The bookmarks sync across all devices the user logs in on via the
|
||||
* bootstrap relays.
|
||||
*/
|
||||
|
||||
#ifndef BOOKMARKS_H
|
||||
#define BOOKMARKS_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <time.h>
|
||||
|
||||
/* nostr_signer_t is needed for the init function */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define BOOKMARKS_MAX_PER_NODE 500
|
||||
#define BOOKMARKS_NAME_MAX 128
|
||||
#define BOOKMARKS_PATH_MAX 1024
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
|
||||
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
|
||||
* per application and can be rotated independently if ever needed. */
|
||||
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
char *title;
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
/* A node in the bookmark tree. The root node has name="" and path="".
|
||||
* Each node corresponds to a folder path; bookmarks live at any node. */
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last path segment, "" for root */
|
||||
char *path; /* full path, "" for root */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_t;
|
||||
|
||||
/*
|
||||
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
|
||||
* database and decrypts them using the signer.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login).
|
||||
* The signer holds the private key; d tags are derived via
|
||||
* nostr_signer_derive_hmac, so the privkey never lives in
|
||||
* browser memory.
|
||||
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int bookmarks_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark tree. Call at shutdown. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* ── Read access ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Get the root node of the bookmark tree (read-only). Returns NULL if
|
||||
* bookmarks_init has not been called. The returned pointer is valid until
|
||||
* the next bookmarks mutation. */
|
||||
const bookmark_node_t *bookmarks_get_root(void);
|
||||
|
||||
/* Find a node by path. Returns NULL if not found. */
|
||||
const bookmark_node_t *bookmarks_find(const char *path);
|
||||
|
||||
/* ── Write access (requires signer + privkey) ──────────────────────── */
|
||||
|
||||
/* Add a bookmark to a folder path. If the folder (or any intermediate
|
||||
* folder) doesn't exist, it is created. Updates the in-memory tree,
|
||||
* re-encrypts, publishes a new kind 30003 event for that path, and stores
|
||||
* in SQLite.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *path, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a folder path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *path, const char *url);
|
||||
|
||||
/* Rename a bookmark's title (the user-editable label) in place. The
|
||||
* bookmark is located by (path, url); only its title changes. Re-encrypts
|
||||
* and publishes a new kind 30003 event for that path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_rename(const char *path, const char *url, const char *new_title);
|
||||
|
||||
/* Move a bookmark from one folder to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_path, const char *url,
|
||||
const char *to_path);
|
||||
|
||||
/* Create a new empty folder at the given path. Parent folders are created
|
||||
* if missing. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if the folder already exists / error. */
|
||||
int bookmarks_create_dir(const char *path);
|
||||
|
||||
/* Rename a folder. Re-publishes the renamed node and every descendant with
|
||||
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
|
||||
* old d tags.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_rename_dir(const char *old_path, const char *new_path);
|
||||
|
||||
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
|
||||
* its descendants' bookmarks) are moved to "General" before deletion.
|
||||
* Emits kind 5 deletion events for every d tag in the subtree.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *path, int move_to_general);
|
||||
|
||||
/* ── Change notification ───────────────────────────────────────────── */
|
||||
|
||||
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
|
||||
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
|
||||
* refresh every open tab. */
|
||||
typedef void (*bookmarks_changed_cb)(void *user_data);
|
||||
|
||||
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
|
||||
|
||||
/* ── Internal (used by relay fetch) ────────────────────────────────── */
|
||||
|
||||
/* Store a raw kind 30003 event in the database and decrypt its content
|
||||
* into the in-memory tree. Called by the relay fetch after login. */
|
||||
int bookmarks_store_and_load_event(const void *event_cjson);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* BOOKMARKS_H */
|
||||
13
src/cli.c
13
src/cli.c
@@ -64,7 +64,6 @@ void cli_print_usage(FILE *fp) {
|
||||
" --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"
|
||||
@@ -105,7 +104,6 @@ enum {
|
||||
OPT_NSIGNER_DEVICE,
|
||||
OPT_NSIGNER_SERVICE,
|
||||
OPT_NSIGNER_INDEX,
|
||||
OPT_NO_SAVE_IDENTITY,
|
||||
OPT_NO_LOGIN,
|
||||
OPT_LOGIN_TIMEOUT,
|
||||
OPT_VERBOSE,
|
||||
@@ -139,7 +137,6 @@ static struct option long_opts[] = {
|
||||
{"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 */
|
||||
@@ -337,9 +334,6 @@ int cli_parse(int *argc, char ***argv, cli_args_t *out) {
|
||||
return -1;
|
||||
}
|
||||
break;
|
||||
case OPT_NO_SAVE_IDENTITY:
|
||||
out->no_save_identity = TRUE;
|
||||
break;
|
||||
case OPT_NO_LOGIN:
|
||||
out->no_login = TRUE;
|
||||
break;
|
||||
@@ -471,11 +465,10 @@ int cli_login(const cli_args_t *args) {
|
||||
method ? method : "?", pubkey ? pubkey : "?");
|
||||
}
|
||||
cJSON_Delete(result);
|
||||
/* Private keys are never persisted to disk — they live only in RAM
|
||||
* for the duration of the session. The identity is held in the
|
||||
* global app_state_t (main.c) and is gone when the browser quits. */
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ typedef struct {
|
||||
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. */
|
||||
|
||||
780
src/db.c
780
src/db.c
@@ -16,6 +16,7 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
|
||||
/* ── Global state ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -24,24 +25,44 @@ static sqlite3 *g_db = NULL;
|
||||
/* Static buffer for db_kv_get() — valid until the next call. */
|
||||
static char g_kv_buf[4096];
|
||||
|
||||
/* ── Path helper ───────────────────────────────────────────────────── */
|
||||
/* ── Path helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
static int db_path(char *out, size_t out_sz) {
|
||||
/* ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
|
||||
static int sb_home_dir(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ~/.sovereign_browser/browser.db (legacy default per-user path). */
|
||||
static int db_path(char *out, size_t out_sz) {
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
if (sb_home_dir(dir, sizeof(dir)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
int n = snprintf(out, out_sz, "%s/browser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/browser.db", dir);
|
||||
/* ~/.sovereign_browser/global.db (global settings + shortcuts). */
|
||||
static int db_global_path(char *out, size_t out_sz) {
|
||||
char dir[512];
|
||||
if (sb_home_dir(dir, sizeof(dir)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/global.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
@@ -72,29 +93,76 @@ static const char *SCHEMA_SQL =
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS history ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" url TEXT NOT NULL UNIQUE,"
|
||||
" title TEXT,"
|
||||
" visited_at INTEGER NOT NULL,"
|
||||
" visit_count INTEGER DEFAULT 1"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visited_at "
|
||||
" ON history(visited_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_url "
|
||||
" ON history(url);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_title "
|
||||
" ON history(title);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_visit_count "
|
||||
" ON history(visit_count DESC);"
|
||||
"CREATE TABLE IF NOT EXISTS session ("
|
||||
" tab_index INTEGER PRIMARY KEY,"
|
||||
" url TEXT NOT NULL,"
|
||||
" title TEXT"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
|
||||
" ON events(pubkey, kind, created_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
|
||||
" ON event_tags(tag_name, tag_value);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
|
||||
" ON event_tags(event_id);";
|
||||
" ON event_tags(event_id);"
|
||||
"CREATE TABLE IF NOT EXISTS agent_sessions ("
|
||||
" id TEXT PRIMARY KEY,"
|
||||
" title TEXT,"
|
||||
" created_at INTEGER,"
|
||||
" updated_at INTEGER"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS agent_messages ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" session_id TEXT,"
|
||||
" role TEXT,"
|
||||
" content TEXT,"
|
||||
" tool_calls TEXT,"
|
||||
" tool_call_id TEXT,"
|
||||
" created_at INTEGER,"
|
||||
" FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session "
|
||||
" ON agent_messages(session_id, created_at);";
|
||||
|
||||
/* ── 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");
|
||||
/*
|
||||
* Open a SQLite database at the given path with the standard schema.
|
||||
* Closes any currently-open database first. Returns 0 on success.
|
||||
*/
|
||||
int db_init_with_path(const char *path) {
|
||||
if (path == NULL || path[0] == '\0') {
|
||||
g_printerr("[db] db_init_with_path: NULL path\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Close any currently-open database so we can switch databases. */
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
}
|
||||
|
||||
int rc = sqlite3_open_v2(path, &g_db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
|
||||
SQLITE_OPEN_FULLMUTEX,
|
||||
NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to open database: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
g_printerr("[db] Failed to open database '%s': %s\n",
|
||||
path, g_db ? sqlite3_errmsg(g_db) : "(null)");
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
g_db = NULL;
|
||||
@@ -114,7 +182,8 @@ int db_init(void) {
|
||||
if (err) sqlite3_free(err);
|
||||
}
|
||||
|
||||
/* Create schema. */
|
||||
/* Create schema (all tables use IF NOT EXISTS, so this is safe for
|
||||
* both global.db and per-user browser.db). */
|
||||
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Failed to create schema: %s\n",
|
||||
@@ -129,6 +198,33 @@ int db_init(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Open the global database at ~/.sovereign_browser/global.db.
|
||||
* Used at startup for global settings + shortcuts, before login.
|
||||
*/
|
||||
int db_init_global(void) {
|
||||
char path[512];
|
||||
if (db_global_path(path, sizeof(path)) != 0) {
|
||||
g_printerr("[db] Failed to get global database path\n");
|
||||
return -1;
|
||||
}
|
||||
return db_init_with_path(path);
|
||||
}
|
||||
|
||||
/*
|
||||
* Open the default per-user database at ~/.sovereign_browser/browser.db.
|
||||
* Kept for compatibility; the normal flow now uses db_init_with_path()
|
||||
* with a profile-specific path after login.
|
||||
*/
|
||||
int db_init(void) {
|
||||
char path[512];
|
||||
if (db_path(path, sizeof(path)) != 0) {
|
||||
g_printerr("[db] Failed to get database path\n");
|
||||
return -1;
|
||||
}
|
||||
return db_init_with_path(path);
|
||||
}
|
||||
|
||||
void db_close(void) {
|
||||
if (g_db) {
|
||||
sqlite3_close(g_db);
|
||||
@@ -355,6 +451,90 @@ int db_count_events(const char *pubkey_hex, int kind) {
|
||||
return count;
|
||||
}
|
||||
|
||||
cJSON *db_get_events_by_kind(int kind, int limit) {
|
||||
if (g_db == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int lim = (limit > 0) ? limit : 10000;
|
||||
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT raw_json FROM events "
|
||||
"WHERE kind = ? "
|
||||
"ORDER BY created_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] Prepare get_events_by_kind failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_int(stmt, 1, kind);
|
||||
sqlite3_bind_int(stmt, 2, 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_delete_event(const char *event_id) {
|
||||
if (g_db == NULL || event_id == NULL) return -1;
|
||||
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete tags first. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
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, event_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
/* Delete the event row. */
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"DELETE FROM events WHERE id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] delete_event prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return -1;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] delete_event step failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
int db_kv_set(const char *key, const char *value) {
|
||||
@@ -374,6 +554,66 @@ int db_kv_set(const char *key, const char *value) {
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Write a key-value pair to a specific database file, opening a separate
|
||||
* short-lived connection. This is used to save global settings to
|
||||
* global.db while the per-user browser.db is the main open database.
|
||||
* Creates the key_value table if it doesn't exist (safe for a fresh
|
||||
* global.db). Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set_to_file(const char *path, const char *key, const char *value) {
|
||||
if (path == NULL || key == NULL || value == NULL) return -1;
|
||||
|
||||
sqlite3 *db = NULL;
|
||||
int rc = sqlite3_open_v2(path, &db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
|
||||
SQLITE_OPEN_FULLMUTEX,
|
||||
NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: open '%s' failed: %s\n",
|
||||
path, db ? sqlite3_errmsg(db) : "(null)");
|
||||
if (db) sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_busy_timeout(db, 5000);
|
||||
|
||||
/* Ensure the key_value table exists (harmless if already present). */
|
||||
char *err = NULL;
|
||||
rc = sqlite3_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS key_value ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
");", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: create table failed: %s\n",
|
||||
err ? err : "(unknown)");
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(db,
|
||||
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] db_kv_set_to_file: prepare failed: %s\n",
|
||||
sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
const char *db_kv_get(const char *key) {
|
||||
if (g_db == NULL || key == NULL) return NULL;
|
||||
|
||||
@@ -402,3 +642,515 @@ char *db_kv_get_copy(const char *key) {
|
||||
if (val == NULL) return NULL;
|
||||
return g_strdup(val);
|
||||
}
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_add(const char *url, const char *title) {
|
||||
if (g_db == NULL || url == NULL || url[0] == '\0') return -1;
|
||||
|
||||
/* UPSERT: insert or update visited_at + visit_count. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO history (url, title, visited_at, visit_count) "
|
||||
"VALUES (?, ?, strftime('%s','now'), 1) "
|
||||
"ON CONFLICT(url) DO UPDATE SET "
|
||||
" title = excluded.title, "
|
||||
" visited_at = strftime('%s','now'), "
|
||||
" visit_count = visit_count + 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||
}
|
||||
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
if (limit <= 0) limit = 50;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"ORDER BY visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
sqlite3_bind_int(stmt, 1, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_history_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── History search ────────────────────────────────────────────────── */
|
||||
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit) {
|
||||
if (g_db == NULL || query == NULL || urls_out == NULL || count_out == NULL)
|
||||
return -1;
|
||||
if (limit <= 0) limit = 10;
|
||||
|
||||
/* Build a LIKE pattern: %query% (case-insensitive via COLLATE NOCASE
|
||||
* is not needed — SQLite LIKE is case-insensitive for ASCII by default). */
|
||||
char *pattern = g_strdup_printf("%%%s%%", query);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM history "
|
||||
"WHERE url LIKE ? OR title LIKE ? "
|
||||
"ORDER BY visit_count DESC, visited_at DESC LIMIT ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_free(pattern);
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, pattern, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern, -1, NULL); /* same pattern, freed once */
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Re-bind (sqlite3_reset keeps bindings, but reset clears them in some
|
||||
* versions — re-bind to be safe). */
|
||||
char *pattern2 = g_strdup_printf("%%%s%%", query);
|
||||
sqlite3_bind_text(stmt, 1, pattern2, -1, g_free);
|
||||
sqlite3_bind_text(stmt, 2, pattern2, -1, NULL);
|
||||
sqlite3_bind_int(stmt, 3, limit);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
if (count_out) *count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (count_out) *count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
int db_session_save(const char **urls, const char **titles, int count) {
|
||||
if (g_db == NULL || urls == NULL) return -1;
|
||||
|
||||
/* Clear the session table first. */
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Insert each tab. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (urls[i] == NULL) continue;
|
||||
sqlite3_bind_int(stmt, 1, i);
|
||||
sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3,
|
||||
(titles && titles[i]) ? titles[i] : "",
|
||||
-1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_reset(stmt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out) {
|
||||
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
|
||||
|
||||
/* Count rows. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT url, title FROM session ORDER BY tab_index;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) return -1;
|
||||
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
if (count == 0) {
|
||||
sqlite3_finalize(stmt);
|
||||
*count_out = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
*urls_out = g_new0(char *, count);
|
||||
if (titles_out) *titles_out = g_new0(char *, count);
|
||||
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *url = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*urls_out)[i] = g_strdup(url ? url : "");
|
||||
if (titles_out) {
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
*count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_session_clear(void) {
|
||||
if (g_db == NULL) return -1;
|
||||
char *err = NULL;
|
||||
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (err) sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Agent chat sessions ───────────────────────────────────────────── */
|
||||
|
||||
char *db_agent_session_create(const char *title) {
|
||||
if (g_db == NULL) return NULL;
|
||||
|
||||
char *id = g_uuid_string_random();
|
||||
if (id == NULL) {
|
||||
g_printerr("[db] agent_session_create: g_uuid_string_random failed\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
long now = (long)time(NULL);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO agent_sessions (id, title, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_create: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
g_free(id);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, now);
|
||||
sqlite3_bind_int64(stmt, 4, now);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] agent_session_create: insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
g_free(id);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
char *db_agent_session_get_latest(void) {
|
||||
if (g_db == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_get_latest: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *result = NULL;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *id = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (id) {
|
||||
result = g_strdup(id);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
int db_agent_session_update(const char *session_id, const char *title) {
|
||||
if (g_db == NULL || session_id == NULL) return -1;
|
||||
|
||||
long now = (long)time(NULL);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"UPDATE agent_sessions SET title = ?, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_update: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, title ? title : "", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 2, now);
|
||||
sqlite3_bind_text(stmt, 3, session_id, -1, SQLITE_TRANSIENT);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] agent_session_update: update failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int db_agent_session_list(char ***ids_out, char ***titles_out,
|
||||
int *count_out) {
|
||||
if (g_db == NULL || ids_out == NULL || titles_out == NULL ||
|
||||
count_out == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT id, title FROM agent_sessions "
|
||||
"ORDER BY updated_at DESC;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_list: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* First pass: count rows. */
|
||||
int count = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
|
||||
sqlite3_reset(stmt);
|
||||
|
||||
/* Allocate arrays. */
|
||||
*ids_out = g_new0(char *, count);
|
||||
*titles_out = g_new0(char *, count);
|
||||
*count_out = 0;
|
||||
|
||||
/* Second pass: fill. */
|
||||
int i = 0;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
|
||||
const char *id = (const char *)sqlite3_column_text(stmt, 0);
|
||||
const char *title = (const char *)sqlite3_column_text(stmt, 1);
|
||||
(*ids_out)[i] = g_strdup(id ? id : "");
|
||||
(*titles_out)[i] = g_strdup(title ? title : "");
|
||||
i++;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
*count_out = i;
|
||||
return i;
|
||||
}
|
||||
|
||||
int db_agent_message_add(const char *session_id, const char *role,
|
||||
const char *content, const char *tool_calls,
|
||||
const char *tool_call_id) {
|
||||
if (g_db == NULL || session_id == NULL || role == NULL) return -1;
|
||||
|
||||
long now = (long)time(NULL);
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"INSERT INTO agent_messages "
|
||||
"(session_id, role, content, tool_calls, tool_call_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?);",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_message_add: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, role, -1, SQLITE_TRANSIENT);
|
||||
if (content) {
|
||||
sqlite3_bind_text(stmt, 3, content, -1, SQLITE_TRANSIENT);
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 3);
|
||||
}
|
||||
if (tool_calls) {
|
||||
sqlite3_bind_text(stmt, 4, tool_calls, -1, SQLITE_TRANSIENT);
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
}
|
||||
if (tool_call_id) {
|
||||
sqlite3_bind_text(stmt, 5, tool_call_id, -1, SQLITE_TRANSIENT);
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
}
|
||||
sqlite3_bind_int64(stmt, 6, now);
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] agent_message_add: insert failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (int)sqlite3_last_insert_rowid(g_db);
|
||||
}
|
||||
|
||||
cJSON *db_agent_message_list(const char *session_id) {
|
||||
if (g_db == NULL || session_id == NULL) return NULL;
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"SELECT id, role, content, tool_calls, tool_call_id "
|
||||
"FROM agent_messages WHERE session_id = ? "
|
||||
"ORDER BY created_at ASC, id ASC;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_message_list: prepare failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||
|
||||
cJSON *array = cJSON_CreateArray();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON *msg = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(msg, "id",
|
||||
(double)sqlite3_column_int64(stmt, 0));
|
||||
|
||||
const char *role = (const char *)sqlite3_column_text(stmt, 1);
|
||||
cJSON_AddStringToObject(msg, "role", role ? role : "");
|
||||
|
||||
/* content may be NULL. */
|
||||
if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) {
|
||||
cJSON_AddNullToObject(msg, "content");
|
||||
} else {
|
||||
const char *content = (const char *)sqlite3_column_text(stmt, 2);
|
||||
cJSON_AddStringToObject(msg, "content", content ? content : "");
|
||||
}
|
||||
|
||||
/* tool_calls may be NULL. */
|
||||
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
|
||||
cJSON_AddNullToObject(msg, "tool_calls");
|
||||
} else {
|
||||
const char *tc = (const char *)sqlite3_column_text(stmt, 3);
|
||||
cJSON_AddStringToObject(msg, "tool_calls", tc ? tc : "");
|
||||
}
|
||||
|
||||
/* tool_call_id may be NULL. */
|
||||
if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) {
|
||||
cJSON_AddNullToObject(msg, "tool_call_id");
|
||||
} else {
|
||||
const char *tcid = (const char *)sqlite3_column_text(stmt, 4);
|
||||
cJSON_AddStringToObject(msg, "tool_call_id", tcid ? tcid : "");
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(array, msg);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return array;
|
||||
}
|
||||
|
||||
int db_agent_session_delete(const char *session_id) {
|
||||
if (g_db == NULL || session_id == NULL) return -1;
|
||||
|
||||
/* Delete messages first (in case foreign keys are not enforced). */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(g_db,
|
||||
"DELETE FROM agent_messages WHERE session_id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_delete: prepare messages failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] agent_session_delete: delete messages failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete the session row. */
|
||||
rc = sqlite3_prepare_v2(g_db,
|
||||
"DELETE FROM agent_sessions WHERE id = ?;",
|
||||
-1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
g_printerr("[db] agent_session_delete: prepare session failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
g_printerr("[db] agent_session_delete: delete session failed: %s\n",
|
||||
sqlite3_errmsg(g_db));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
176
src/db.h
176
src/db.h
@@ -28,14 +28,33 @@ extern "C" {
|
||||
/*
|
||||
* Initialize the database at ~/.sovereign_browser/browser.db.
|
||||
* Creates tables and indexes if they don't exist.
|
||||
* Call once at startup (after settings_load()).
|
||||
* Kept for compatibility; prefer db_init_with_path() for per-user dbs.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init(void);
|
||||
|
||||
/*
|
||||
* Close the database. Call at shutdown.
|
||||
* Initialize the global database at ~/.sovereign_browser/global.db.
|
||||
* Used at startup for global settings + shortcuts, before login.
|
||||
* Creates tables and indexes if they don't exist.
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init_global(void);
|
||||
|
||||
/*
|
||||
* Open a SQLite database at the given path with the standard schema.
|
||||
* Closes any currently-open database first (so you can switch from
|
||||
* global.db to a per-user browser.db after login).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_init_with_path(const char *path);
|
||||
|
||||
/*
|
||||
* Close the database. Call at shutdown, or before switching to a
|
||||
* different database via db_init_with_path().
|
||||
*/
|
||||
void db_close(void);
|
||||
|
||||
@@ -76,14 +95,41 @@ cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
|
||||
*/
|
||||
int db_count_events(const char *pubkey_hex, int kind);
|
||||
|
||||
/*
|
||||
* Fetch all events of a given kind from ALL authors (no pubkey filter),
|
||||
* newest first. Used for public events like kind 31123 skills that are
|
||||
* authored by anyone.
|
||||
*
|
||||
* 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_by_kind(int kind, int limit);
|
||||
|
||||
/*
|
||||
* Delete a single event by its event id (and its tags, via cascade).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_delete_event(const char *event_id);
|
||||
|
||||
/* ── Key-Value store ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Set a key-value pair (upsert).
|
||||
* Set a key-value pair (upsert) in the currently-open database.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set(const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Set a key-value pair in a specific database file, opening a separate
|
||||
* short-lived connection. Used to save global settings to global.db
|
||||
* while the per-user browser.db is the main open database. Creates the
|
||||
* key_value table if it doesn't exist.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_kv_set_to_file(const char *path, const char *key, const char *value);
|
||||
|
||||
/*
|
||||
* Get a value by key.
|
||||
* Returns a pointer to the value string, or NULL if not found.
|
||||
@@ -98,6 +144,130 @@ const char *db_kv_get(const char *key);
|
||||
*/
|
||||
char *db_kv_get_copy(const char *key);
|
||||
|
||||
/* ── History ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Add a URL to the history (UPSERT — increments visit_count and updates
|
||||
* visited_at if the URL already exists).
|
||||
* url — the URL to record
|
||||
* title — optional page title (NULL or "" for none)
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_add(const char *url, const char *title);
|
||||
|
||||
/*
|
||||
* Get recent history entries (most-recent-first).
|
||||
* limit — max number of entries (0 = default 50)
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_get(char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/*
|
||||
* Clear all history entries.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_history_clear(void);
|
||||
|
||||
/*
|
||||
* Search history entries by URL or title substring.
|
||||
* query — substring to search for (case-insensitive)
|
||||
* limit — max number of entries (0 = default 10)
|
||||
*
|
||||
* Results are ranked by visit_count DESC, then visited_at DESC — so
|
||||
* frequently-visited and recently-visited sites appear first.
|
||||
*
|
||||
* Fills urls_out and titles_out with parallel arrays of newly allocated
|
||||
* strings. Caller must free each string and the arrays themselves.
|
||||
* Returns the number of entries, or -1 on error.
|
||||
*/
|
||||
int db_history_search(const char *query,
|
||||
char ***urls_out, char ***titles_out,
|
||||
int *count_out, int limit);
|
||||
|
||||
/* ── Session ───────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Save the current session (open tab URLs) to the database.
|
||||
* Clears the session table first, then inserts the given URLs in order.
|
||||
* urls — array of URL strings
|
||||
* titles — array of title strings (can be NULL for no titles)
|
||||
* count — number of tabs
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_save(const char **urls, const char **titles, int count);
|
||||
|
||||
/*
|
||||
* Load the saved session from the database.
|
||||
* Fills urls_out and titles_out with parallel arrays (tab_index order).
|
||||
* Caller must free each string and the arrays.
|
||||
* Returns the number of tabs, or -1 on error / no session.
|
||||
*/
|
||||
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
|
||||
/*
|
||||
* Clear the saved session.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_session_clear(void);
|
||||
|
||||
/* ── Agent chat sessions ───────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Create a new chat session. Returns the session ID (newly allocated string,
|
||||
* caller must g_free). Returns NULL on error.
|
||||
*/
|
||||
char *db_agent_session_create(const char *title);
|
||||
|
||||
/*
|
||||
* Get the most recent chat session ID. Returns a newly allocated string,
|
||||
* or NULL if no sessions exist. Caller must g_free.
|
||||
*/
|
||||
char *db_agent_session_get_latest(void);
|
||||
|
||||
/*
|
||||
* Update a session's title and updated_at timestamp.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_agent_session_update(const char *session_id, const char *title);
|
||||
|
||||
/*
|
||||
* List all chat sessions, newest first. Fills arrays with session IDs and
|
||||
* titles. Caller must free each string and the arrays.
|
||||
* Returns the number of sessions, or -1 on error.
|
||||
*/
|
||||
int db_agent_session_list(char ***ids_out, char ***titles_out,
|
||||
int *count_out);
|
||||
|
||||
/*
|
||||
* Add a message to a chat session.
|
||||
* role — "user", "assistant", "tool", or "system"
|
||||
* content — message text (may be NULL for assistant msgs with only tool_calls)
|
||||
* tool_calls — JSON string of tool calls array (may be NULL)
|
||||
* tool_call_id — for role="tool", the ID of the tool call this responds to (may be NULL)
|
||||
* Returns the message row ID (>0) on success, -1 on error.
|
||||
*/
|
||||
int db_agent_message_add(const char *session_id, const char *role,
|
||||
const char *content, const char *tool_calls,
|
||||
const char *tool_call_id);
|
||||
|
||||
/*
|
||||
* Load all messages for a session, ordered by created_at (oldest first).
|
||||
* Returns a cJSON array of message objects, each with:
|
||||
* {"id":N, "role":"...", "content":"...", "tool_calls":"...", "tool_call_id":"..."}
|
||||
* Returns NULL on error. Caller must cJSON_Delete().
|
||||
*/
|
||||
cJSON *db_agent_message_list(const char *session_id);
|
||||
|
||||
/*
|
||||
* Delete a chat session and all its messages.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int db_agent_session_delete(const char *session_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
257
src/fips_control.c
Normal file
257
src/fips_control.c
Normal file
@@ -0,0 +1,257 @@
|
||||
/* fips_control.c — synchronous FIPS JSON-line control client */
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "fips_control.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define FIPS_RESPONSE_MAX (1024 * 1024)
|
||||
|
||||
static void set_error(char *out, size_t size, const char *format, const char *detail) {
|
||||
if (out && size) snprintf(out, size, format, detail ? detail : "unknown error");
|
||||
}
|
||||
|
||||
static int wait_fd(int fd, short events, int timeout_ms) {
|
||||
struct pollfd pfd = { fd, events, 0 };
|
||||
int rc;
|
||||
do { rc = poll(&pfd, 1, timeout_ms); } while (rc < 0 && errno == EINTR);
|
||||
return rc > 0 && (pfd.revents & events) ? 0 : -1;
|
||||
}
|
||||
|
||||
int fips_control_connect(const char *socket_path, int timeout_ms,
|
||||
char *error, size_t error_size) {
|
||||
if (!socket_path || !socket_path[0]) {
|
||||
set_error(error, error_size, "%s", "empty FIPS control socket path");
|
||||
return -1;
|
||||
}
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) {
|
||||
set_error(error, error_size, "FIPS socket failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
if (strlen(socket_path) >= sizeof(addr.sun_path)) {
|
||||
close(fd);
|
||||
set_error(error, error_size, "%s", "FIPS control socket path too long");
|
||||
return -1;
|
||||
}
|
||||
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", socket_path);
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (rc != 0 && errno == EINPROGRESS) {
|
||||
if (wait_fd(fd, POLLOUT, timeout_ms) == 0) {
|
||||
int socket_error = 0;
|
||||
socklen_t len = sizeof(socket_error);
|
||||
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &len) == 0 &&
|
||||
socket_error == 0) rc = 0;
|
||||
else { errno = socket_error; rc = -1; }
|
||||
} else rc = -1;
|
||||
}
|
||||
if (rc != 0) {
|
||||
set_error(error, error_size, "FIPS connect failed: %s", strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
fcntl(fd, F_SETFL, flags);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int write_all(int fd, const char *text) {
|
||||
size_t length = strlen(text), sent = 0;
|
||||
while (sent < length) {
|
||||
if (wait_fd(fd, POLLOUT, 1500) != 0) return -1;
|
||||
ssize_t n = send(fd, text + sent, length - sent, MSG_NOSIGNAL);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) return -1;
|
||||
sent += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON *request(int fd, cJSON *request_obj, char *error, size_t error_size) {
|
||||
char *json = cJSON_PrintUnformatted(request_obj);
|
||||
if (!json) {
|
||||
set_error(error, error_size, "%s", "cannot serialize FIPS request");
|
||||
return NULL;
|
||||
}
|
||||
char *line = g_strdup_printf("%s\n", json);
|
||||
cJSON_free(json);
|
||||
if (write_all(fd, line) != 0) {
|
||||
g_free(line);
|
||||
set_error(error, error_size, "FIPS write failed: %s", strerror(errno));
|
||||
return NULL;
|
||||
}
|
||||
g_free(line);
|
||||
|
||||
GString *response = g_string_sized_new(4096);
|
||||
while (response->len < FIPS_RESPONSE_MAX) {
|
||||
if (wait_fd(fd, POLLIN, 1500) != 0) {
|
||||
g_string_free(response, TRUE);
|
||||
set_error(error, error_size, "%s", "FIPS response timed out");
|
||||
return NULL;
|
||||
}
|
||||
char buffer[4096];
|
||||
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) {
|
||||
g_string_free(response, TRUE);
|
||||
set_error(error, error_size, "%s", "FIPS closed control connection");
|
||||
return NULL;
|
||||
}
|
||||
char *newline = memchr(buffer, '\n', (size_t)n);
|
||||
if (newline) {
|
||||
g_string_append_len(response, buffer, newline - buffer);
|
||||
break;
|
||||
}
|
||||
g_string_append_len(response, buffer, n);
|
||||
}
|
||||
cJSON *root = cJSON_Parse(response->str);
|
||||
g_string_free(response, TRUE);
|
||||
if (!root) {
|
||||
set_error(error, error_size, "%s", "invalid FIPS JSON response");
|
||||
return NULL;
|
||||
}
|
||||
cJSON *status = cJSON_GetObjectItemCaseSensitive(root, "status");
|
||||
if (!cJSON_IsString(status) || strcmp(status->valuestring, "ok") != 0) {
|
||||
cJSON *message = cJSON_GetObjectItemCaseSensitive(root, "message");
|
||||
set_error(error, error_size, "FIPS control error: %s",
|
||||
cJSON_IsString(message) ? message->valuestring : "request failed");
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
static void copy_json_string(cJSON *object, const char *name, char *out, size_t size) {
|
||||
cJSON *item = cJSON_GetObjectItemCaseSensitive(object, name);
|
||||
if (cJSON_IsString(item) && size) snprintf(out, size, "%s", item->valuestring);
|
||||
}
|
||||
|
||||
int fips_control_show_status(int fd, fips_status_t *status,
|
||||
char *error, size_t error_size) {
|
||||
if (!status) return -1;
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "show_status");
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return -1;
|
||||
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
if (!cJSON_IsObject(data)) {
|
||||
cJSON_Delete(root);
|
||||
set_error(error, error_size, "%s", "FIPS status response lacks data");
|
||||
return -1;
|
||||
}
|
||||
memset(status, 0, sizeof(*status));
|
||||
copy_json_string(data, "npub", status->npub, sizeof(status->npub));
|
||||
copy_json_string(data, "tun_state", status->tun_state, sizeof(status->tun_state));
|
||||
copy_json_string(data, "tun_name", status->tun_name, sizeof(status->tun_name));
|
||||
copy_json_string(data, "state", status->state, sizeof(status->state));
|
||||
copy_json_string(data, "tree_state", status->tree_state, sizeof(status->tree_state));
|
||||
if (!status->tree_state[0]) copy_json_string(data, "tree", status->tree_state, sizeof(status->tree_state));
|
||||
cJSON *peers = cJSON_GetObjectItemCaseSensitive(data, "peer_count");
|
||||
if (cJSON_IsNumber(peers)) status->peer_count = peers->valueint;
|
||||
status->tun_active = strcmp(status->tun_state, "active") == 0 ||
|
||||
strcmp(status->tun_state, "up") == 0;
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *fips_control_show_peers(int fd, char *error, size_t error_size) {
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "show_peers");
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return NULL;
|
||||
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
|
||||
char *result = printed ? g_strdup(printed) : NULL;
|
||||
cJSON_free(printed);
|
||||
cJSON_Delete(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Generic single-command "show_*" that returns the data field as
|
||||
* compact JSON. Used by show_tree and show_identity_cache, which have
|
||||
* the same response shape as show_peers. */
|
||||
static char *fips_control_show_generic(int fd, const char *command,
|
||||
char *error, size_t error_size) {
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", command);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return NULL;
|
||||
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
|
||||
char *result = printed ? g_strdup(printed) : NULL;
|
||||
cJSON_free(printed);
|
||||
cJSON_Delete(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_tree", error, error_size);
|
||||
}
|
||||
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_identity_cache", error, error_size);
|
||||
}
|
||||
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size) {
|
||||
if (!npub || !npub[0] || !address || !address[0]) {
|
||||
set_error(error, error_size, "%s", "FIPS peer npub and address are required");
|
||||
return -1;
|
||||
}
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "connect");
|
||||
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
|
||||
cJSON_AddStringToObject(params, "npub", npub);
|
||||
cJSON_AddStringToObject(params, "address", address);
|
||||
cJSON_AddStringToObject(params, "transport", transport && transport[0] ? transport : "udp");
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return -1;
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size) {
|
||||
if (!npub || !npub[0]) {
|
||||
set_error(error, error_size, "%s", "FIPS peer npub is required");
|
||||
return -1;
|
||||
}
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "disconnect");
|
||||
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
|
||||
cJSON_AddStringToObject(params, "npub", npub);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return -1;
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void fips_control_close(int *fd) {
|
||||
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
|
||||
}
|
||||
|
||||
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms) {
|
||||
int fd = fips_control_connect(socket_path, timeout_ms, NULL, 0);
|
||||
if (fd < 0) return FALSE;
|
||||
close(fd);
|
||||
return TRUE;
|
||||
}
|
||||
44
src/fips_control.h
Normal file
44
src/fips_control.h
Normal file
@@ -0,0 +1,44 @@
|
||||
/* fips_control.h — FIPS Unix-socket JSON control client */
|
||||
|
||||
#ifndef FIPS_CONTROL_H
|
||||
#define FIPS_CONTROL_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
char npub[80];
|
||||
int peer_count;
|
||||
char tun_state[32];
|
||||
char tun_name[16];
|
||||
char state[32];
|
||||
char tree_state[32];
|
||||
gboolean tun_active;
|
||||
} fips_status_t;
|
||||
|
||||
int fips_control_connect(const char *socket_path, int timeout_ms,
|
||||
char *error, size_t error_size);
|
||||
int fips_control_show_status(int fd, fips_status_t *status,
|
||||
char *error, size_t error_size);
|
||||
/* Returns the response data as compact JSON. Caller frees with g_free(). */
|
||||
char *fips_control_show_peers(int fd, char *error, size_t error_size);
|
||||
/* show_tree / show_identity_cache — same contract as show_peers. */
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size);
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
void fips_control_close(int *fd);
|
||||
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIPS_CONTROL_H */
|
||||
219
src/history.c
219
src/history.c
@@ -1,165 +1,114 @@
|
||||
/*
|
||||
* history.c — URL history tracking for sovereign_browser
|
||||
*
|
||||
* History is stored in the SQLite database (history table) with timestamps
|
||||
* and visit counts. No flat file, no entry cap.
|
||||
*/
|
||||
|
||||
#include "history.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── In-memory history ─────────────────────────────────────────── */
|
||||
|
||||
static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN];
|
||||
static int g_history_count = 0;
|
||||
|
||||
/* ── Path helper ───────────────────────────────────────────────── */
|
||||
|
||||
static int history_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/history.txt", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Persistence ───────────────────────────────────────────────── */
|
||||
|
||||
void history_load(void) {
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_history_count = 0;
|
||||
char line[HISTORY_URL_MAX_LEN];
|
||||
while (g_history_count < HISTORY_MAX_ENTRIES &&
|
||||
fgets(line, sizeof(line), f) != NULL) {
|
||||
/* Strip newline. */
|
||||
size_t len = strlen(line);
|
||||
if (len > 0 && line[len - 1] == '\n') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line);
|
||||
g_history_count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void history_save(void) {
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
/* Write in reverse order (most recent first) — same as in-memory order. */
|
||||
for (int i = 0; i < g_history_count; i++) {
|
||||
fprintf(f, "%s\n", g_history[i]);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* ── API ───────────────────────────────────────────────────────── */
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
void history_add(const char *url) {
|
||||
if (url == NULL || url[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
history_add_titled(url, NULL);
|
||||
}
|
||||
|
||||
void history_add_titled(const char *url, const char *title) {
|
||||
if (url == NULL || url[0] == '\0') return;
|
||||
|
||||
/* Skip sovereign:// API endpoints (not pages the user would want
|
||||
* in their recents). The actual pages — sovereign://settings,
|
||||
* sovereign://profile, sovereign://security — are kept in history
|
||||
* so the user can quickly return to them. */
|
||||
* sovereign://profile, sovereign://bookmarks, sovereign://security —
|
||||
* are kept in history so the user can quickly return to them. */
|
||||
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/add", 25) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/rename", 28) == 0 ||
|
||||
strncmp(url, "sovereign://qr", 14) == 0 ||
|
||||
strncmp(url, "sovereign://nostr/", 18) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Check if the URL is already in the history. */
|
||||
int found_idx = -1;
|
||||
for (int i = 0; i < g_history_count; i++) {
|
||||
if (strcmp(g_history[i], url) == 0) {
|
||||
found_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found_idx >= 0) {
|
||||
/* URL already exists — move it to the top. */
|
||||
if (found_idx == 0) {
|
||||
return; /* already at the top, nothing to do */
|
||||
}
|
||||
/* Shift items 0..found_idx-1 down by one. */
|
||||
char tmp[HISTORY_URL_MAX_LEN];
|
||||
snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]);
|
||||
for (int i = found_idx; i > 0; i--) {
|
||||
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
|
||||
}
|
||||
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp);
|
||||
} else {
|
||||
/* New URL — shift everything down by one to make room at the top. */
|
||||
if (g_history_count >= HISTORY_MAX_ENTRIES) {
|
||||
g_history_count = HISTORY_MAX_ENTRIES - 1;
|
||||
}
|
||||
for (int i = g_history_count; i > 0; i--) {
|
||||
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
|
||||
}
|
||||
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url);
|
||||
g_history_count++;
|
||||
}
|
||||
|
||||
history_save();
|
||||
db_history_add(url, title);
|
||||
}
|
||||
|
||||
int history_count(void) {
|
||||
return g_history_count;
|
||||
/* Query the count from the database via db_history_get.
|
||||
* For the Recents submenu we only show 20, but we need the
|
||||
* total count to decide whether to show the submenu. */
|
||||
char **urls = NULL;
|
||||
int count = 0;
|
||||
int rc = db_history_get(&urls, NULL, &count, 10000);
|
||||
if (rc < 0) return 0;
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const char *history_get(int index) {
|
||||
if (index < 0 || index >= g_history_count) {
|
||||
char *history_get(int index) {
|
||||
if (index < 0) return NULL;
|
||||
|
||||
char **urls = NULL;
|
||||
int count = 0;
|
||||
db_history_get(&urls, NULL, &count, index + 1);
|
||||
if (urls == NULL || count <= index) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
return g_history[index];
|
||||
|
||||
char *result = urls[index];
|
||||
/* Free the other entries. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i != index) g_free(urls[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *history_get_title(int index) {
|
||||
if (index < 0) return NULL;
|
||||
|
||||
char **urls = NULL;
|
||||
char **titles = NULL;
|
||||
int count = 0;
|
||||
db_history_get(&urls, &titles, &count, index + 1);
|
||||
if (urls == NULL || count <= index) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
if (titles) {
|
||||
for (int i = 0; i < count; i++) g_free(titles[i]);
|
||||
g_free(titles);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *result = titles[index];
|
||||
/* Free everything except the result. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i != index) g_free(titles[i]);
|
||||
g_free(urls[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
return result;
|
||||
}
|
||||
|
||||
void history_clear(void) {
|
||||
g_history_count = 0;
|
||||
|
||||
char path[512];
|
||||
if (history_path(path, sizeof(path)) == 0) {
|
||||
unlink(path);
|
||||
}
|
||||
db_history_clear();
|
||||
g_print("[history] cleared\n");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/*
|
||||
* history.h — URL history tracking for sovereign_browser
|
||||
*
|
||||
* Keeps a list of recently visited URLs (most recent first) and
|
||||
* provides them for the History submenu.
|
||||
* History is stored in the SQLite database (history table) with timestamps
|
||||
* and visit counts. There is no entry cap — the database handles unlimited
|
||||
* entries. The Recents submenu shows the most recent 20.
|
||||
*/
|
||||
|
||||
#ifndef HISTORY_H
|
||||
@@ -14,35 +15,37 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define HISTORY_MAX_ENTRIES 50
|
||||
#define HISTORY_URL_MAX_LEN 2048
|
||||
|
||||
/*
|
||||
* Add a URL to the history. If the URL is already the most recent
|
||||
* entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES.
|
||||
* Also persists to ~/.sovereign_browser/history.txt.
|
||||
* Add a URL to the history. UPSERT — if the URL already exists,
|
||||
* increments visit_count and updates visited_at. The title is optional
|
||||
* (NULL or "" for no title).
|
||||
*/
|
||||
void history_add(const char *url);
|
||||
void history_add_titled(const char *url, const char *title);
|
||||
|
||||
/*
|
||||
* Get the number of history entries.
|
||||
* Get the number of history entries available (queries the database).
|
||||
* Returns the count, or -1 on error.
|
||||
*/
|
||||
int history_count(void);
|
||||
|
||||
/*
|
||||
* Get a history entry by index (0 = most recent).
|
||||
* Returns NULL if index is out of range.
|
||||
* Get a history URL by index (0 = most recent).
|
||||
* Returns a newly allocated string (caller must g_free), or NULL if
|
||||
* index is out of range.
|
||||
*/
|
||||
const char *history_get(int index);
|
||||
char *history_get(int index);
|
||||
|
||||
/*
|
||||
* Load history from ~/.sovereign_browser/history.txt.
|
||||
* Called once at startup.
|
||||
* Get the title for a history entry by index.
|
||||
* Returns a newly allocated string, or NULL if no title / out of range.
|
||||
*/
|
||||
void history_load(void);
|
||||
char *history_get_title(int index);
|
||||
|
||||
/*
|
||||
* Clear all history entries and delete the file.
|
||||
* Clear all history entries.
|
||||
*/
|
||||
void history_clear(void);
|
||||
|
||||
|
||||
366
src/key_store.c
366
src/key_store.c
@@ -1,209 +1,36 @@
|
||||
/*
|
||||
* key_store.c — Nostr identity persistence for sovereign_browser
|
||||
* key_store.c — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Private keys are NEVER persisted to disk. They live only in RAM for
|
||||
* the duration of the session and are gone when the browser quits.
|
||||
*
|
||||
* This module provides:
|
||||
* - key_store_create_signer() — create a nostr_signer_t from an
|
||||
* in-memory identity struct
|
||||
* - key_store_delete_legacy_file() — defensively delete any
|
||||
* identity.json left by a previous version that persisted keys
|
||||
* - key_store_save_profile_identity() — save public identity info
|
||||
* (method, pubkey_hex) to the per-profile identity.json
|
||||
* - key_store_load_profile_identity() — load public identity info
|
||||
* from the per-profile identity.json
|
||||
*/
|
||||
|
||||
#include "key_store.h"
|
||||
#include "profile.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "nostr_core/nip006.h"
|
||||
#include "nostr_core/nip019.h"
|
||||
|
||||
/* ── Path helpers ─────────────────────────────────────────────── */
|
||||
|
||||
int key_store_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Ensure ~/.sovereign_browser/ exists. */
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/identity.json", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Save / load ──────────────────────────────────────────────── */
|
||||
|
||||
static const char *method_to_string(key_store_method_t m) {
|
||||
switch (m) {
|
||||
case KEY_STORE_METHOD_LOCAL: return "local";
|
||||
case KEY_STORE_METHOD_SEED: return "seed";
|
||||
case KEY_STORE_METHOD_READONLY: return "readonly";
|
||||
case KEY_STORE_METHOD_NIP46: return "nip46";
|
||||
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
static key_store_method_t string_to_method(const char *s) {
|
||||
if (!s) return KEY_STORE_METHOD_NONE;
|
||||
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
|
||||
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
|
||||
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
|
||||
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
|
||||
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
|
||||
return KEY_STORE_METHOD_NONE;
|
||||
}
|
||||
|
||||
int key_store_save(const key_store_identity_t *identity) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Write with restrictive permissions. */
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
|
||||
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_LOCAL ||
|
||||
identity->method == KEY_STORE_METHOD_SEED) {
|
||||
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
|
||||
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
|
||||
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
|
||||
}
|
||||
|
||||
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
|
||||
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
|
||||
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
|
||||
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
|
||||
}
|
||||
|
||||
fprintf(f, "\n}\n");
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
|
||||
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
/* Skip whitespace and colon. */
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
if (*p != '"') return -1;
|
||||
p++;
|
||||
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i < out_sz - 1) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
}
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
return (i > 0 || *p == '"') ? 0 : -1;
|
||||
}
|
||||
|
||||
static int json_extract_int(const char *json, const char *key, int *out) {
|
||||
char pattern[64];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (p == NULL) return -1;
|
||||
|
||||
p += strlen(pattern);
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
char *end;
|
||||
long val = strtol(p, &end, 10);
|
||||
if (end == p) return -1;
|
||||
*out = (int)val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_load(key_store_identity_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
if (errno == ENOENT) return 1; /* no identity file */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Read the whole file (small). */
|
||||
char buf[4096];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
char method_str[32];
|
||||
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
out->method = string_to_method(method_str);
|
||||
if (out->method == KEY_STORE_METHOD_NONE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
|
||||
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
|
||||
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
|
||||
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
|
||||
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
|
||||
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_clear(void) {
|
||||
char path[512];
|
||||
if (key_store_path(path, sizeof(path)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Signer creation ──────────────────────────────────────────── */
|
||||
/* ── Signer creation ──────────────────────────────────────────────── */
|
||||
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
if (identity == NULL) {
|
||||
@@ -257,6 +84,12 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
if (signer && identity->nsigner_index >= 0) {
|
||||
nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index);
|
||||
}
|
||||
/* n_signer's serial and TCP transports require a kind-27235 auth
|
||||
* envelope on every request. Install the default caller identity
|
||||
* so the re-created signer can talk to the device. */
|
||||
if (signer) {
|
||||
key_store_nsigner_set_default_auth(signer);
|
||||
}
|
||||
return signer;
|
||||
#else
|
||||
return NULL;
|
||||
@@ -267,3 +100,152 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── n_signer default caller auth ─────────────────────────────────── *
|
||||
* n_signer's serial and TCP transports require a kind-27235 auth envelope
|
||||
* on every request. Without it the device rejects the call with an
|
||||
* auth-related RPC error (codes 2010-2017) that the client maps to
|
||||
* NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as
|
||||
* "device requires approval" even though no on-device approval is actually
|
||||
* needed.
|
||||
*
|
||||
* This installs the same fixed, well-known caller identity used by n_signer's
|
||||
* own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side
|
||||
* authentication credential only — it does NOT grant access to the device's
|
||||
* mnemonic/keys; the device's own policy still decides which operations are
|
||||
* allowed. No-op for non-nsigner backends.
|
||||
*/
|
||||
int key_store_nsigner_set_default_auth(nostr_signer_t *signer) {
|
||||
if (signer == NULL) {
|
||||
return -1;
|
||||
}
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
/* Match the webserial demo's caller privkey: bytes 1..32. */
|
||||
static const unsigned char default_caller_priv[32] = {
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
|
||||
};
|
||||
return nostr_signer_nsigner_set_auth(signer, default_caller_priv,
|
||||
"sovereign_browser");
|
||||
#else
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Legacy file cleanup ──────────────────────────────────────────── */
|
||||
|
||||
int key_store_delete_legacy_file(void) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json",
|
||||
home);
|
||||
if (n < 0 || (size_t)n >= sizeof(path)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Delete the file if it exists. ENOENT is not an error. */
|
||||
if (unlink(path) != 0 && errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Per-profile identity persistence ──────────────────────────────── *
|
||||
* The per-profile identity.json stores public identity information
|
||||
* (method, pubkey_hex) so the browser can display profile info without
|
||||
* requiring the user to log in first. Private keys are NEVER stored.
|
||||
*/
|
||||
|
||||
int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t method) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
profile_get_identity_path(pubkey_hex, path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
|
||||
cJSON_AddNumberToObject(root, "method", (double)method);
|
||||
|
||||
/* Method name for human readability. */
|
||||
const char *method_name = "none";
|
||||
switch (method) {
|
||||
case KEY_STORE_METHOD_NONE: method_name = "none"; break;
|
||||
case KEY_STORE_METHOD_LOCAL: method_name = "local"; break;
|
||||
case KEY_STORE_METHOD_SEED: method_name = "seed"; break;
|
||||
case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break;
|
||||
case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break;
|
||||
case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break;
|
||||
}
|
||||
cJSON_AddStringToObject(root, "method_name", method_name);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
if (json == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
fputs(json, f);
|
||||
fputc('\n', f);
|
||||
fclose(f);
|
||||
free(json);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_store_load_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t *method_out) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
profile_get_identity_path(pubkey_hex, path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char buf[1024];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = -1;
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
|
||||
if (cJSON_IsString(pk) && strcmp(pk->valuestring, pubkey_hex) == 0) {
|
||||
if (method_out) {
|
||||
cJSON *m = cJSON_GetObjectItemCaseSensitive(root, "method");
|
||||
if (cJSON_IsNumber(m)) {
|
||||
*method_out = (key_store_method_t)m->valuedouble;
|
||||
}
|
||||
}
|
||||
rc = 0;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return rc;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* key_store.h — Nostr identity persistence for sovereign_browser
|
||||
* key_store.h — Nostr identity (in-memory only) for sovereign_browser
|
||||
*
|
||||
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
|
||||
* so the browser can restore the signer on startup without re-prompting.
|
||||
* IMPORTANT: Private keys are NEVER persisted to disk. They live only in
|
||||
* working memory (RAM) for the duration of the session and are gone when
|
||||
* the browser quits. The user must sign in again on each launch.
|
||||
*
|
||||
* The key_store_identity_t struct is populated by the login dialog or CLI
|
||||
* login, held in memory, and used to create a nostr_signer_t. It is not
|
||||
* written to any file.
|
||||
*/
|
||||
|
||||
#ifndef KEY_STORE_H
|
||||
@@ -18,14 +23,14 @@ extern "C" {
|
||||
/* Login methods supported by the browser. */
|
||||
typedef enum {
|
||||
KEY_STORE_METHOD_NONE = 0,
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
|
||||
KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */
|
||||
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
|
||||
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
|
||||
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
|
||||
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
|
||||
} key_store_method_t;
|
||||
|
||||
/* Identity record persisted to disk. */
|
||||
/* Identity record (in-memory only — never persisted). */
|
||||
typedef struct {
|
||||
key_store_method_t method;
|
||||
|
||||
@@ -33,10 +38,10 @@ typedef struct {
|
||||
char pubkey_hex[65];
|
||||
|
||||
/* Hex private key (local + seed methods only; 64 chars + NUL).
|
||||
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
|
||||
* Held in memory only — never written to disk. */
|
||||
char privkey_hex[65];
|
||||
|
||||
/* BIP-39 mnemonic (seed method only). */
|
||||
/* BIP-39 mnemonic (seed method only). In memory only. */
|
||||
char mnemonic[256];
|
||||
|
||||
/* NIP-46 bunker URL (nip46 method only). */
|
||||
@@ -49,32 +54,7 @@ typedef struct {
|
||||
} key_store_identity_t;
|
||||
|
||||
/*
|
||||
* Get the path to the identity file (~/.sovereign_browser/identity.json).
|
||||
* Creates the directory if it doesn't exist.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_path(char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Save an identity to disk.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_save(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Load an identity from disk.
|
||||
* Returns 0 on success, 1 if no identity file exists, -1 on error.
|
||||
*/
|
||||
int key_store_load(key_store_identity_t *out);
|
||||
|
||||
/*
|
||||
* Delete the identity file (logout).
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_clear(void);
|
||||
|
||||
/*
|
||||
* Create a nostr_signer_t from a loaded identity.
|
||||
* Create a nostr_signer_t from an in-memory identity.
|
||||
* For readonly: returns NULL (caller should use pubkey_hex directly).
|
||||
* For nsigner: creates the appropriate transport-backed signer.
|
||||
* Caller must free the returned signer with nostr_signer_free().
|
||||
@@ -82,6 +62,49 @@ int key_store_clear(void);
|
||||
*/
|
||||
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
|
||||
|
||||
/*
|
||||
* Defensively delete any legacy identity.json file from a previous
|
||||
* version that persisted keys to disk. Call once at startup.
|
||||
* Returns 0 on success (including if the file doesn't exist), -1 on error.
|
||||
*/
|
||||
int key_store_delete_legacy_file(void);
|
||||
|
||||
/*
|
||||
* Save public identity info (method, pubkey_hex) to the per-profile
|
||||
* identity.json at ~/.sovereign_browser/profiles/<pubkey>/identity.json.
|
||||
* Private keys are NEVER stored — only public information for display.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t method);
|
||||
|
||||
/*
|
||||
* Load public identity info from the per-profile identity.json.
|
||||
* pubkey_hex — the expected pubkey (must match the file's pubkey)
|
||||
* method_out — if non-NULL, receives the stored login method
|
||||
* Returns 0 on success, -1 if not found or pubkey mismatch.
|
||||
*/
|
||||
int key_store_load_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t *method_out);
|
||||
|
||||
/*
|
||||
* Set a default caller auth identity on an n_signer remote signer.
|
||||
*
|
||||
* n_signer's serial and TCP transports require a kind-27235 auth envelope
|
||||
* on every request (the device rejects unauthenticated calls with an
|
||||
* auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE).
|
||||
* This installs a fixed, well-known caller identity (the same one used by
|
||||
* n_signer's own webserial demo) so the browser can talk to a USB-attached
|
||||
* n_signer without the user having to configure caller credentials.
|
||||
*
|
||||
* Safe to call on any signer; no-op for non-nsigner backends. Call after
|
||||
* the signer is created and before the first verb (e.g. before
|
||||
* nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key).
|
||||
*
|
||||
* Returns 0 on success, non-zero on error (e.g. not an nsigner signer).
|
||||
*/
|
||||
int key_store_nsigner_set_default_auth(nostr_signer_t *signer);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "login_dialog.h"
|
||||
#include "key_store.h"
|
||||
#include "agent_login.h"
|
||||
#include "version.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -111,6 +112,7 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
|
||||
static void on_index_changed(GtkWidget *spin, gpointer user_data);
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
|
||||
#endif
|
||||
@@ -512,6 +514,13 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* n_signer's serial/TCP transports require a kind-27235 auth envelope
|
||||
* on every request. Install the default caller identity (matching
|
||||
* n_signer's webserial demo) before issuing any verbs. Without this
|
||||
* the device rejects the call with an auth error that surfaces as
|
||||
* "code -310". */
|
||||
key_store_nsigner_set_default_auth(signer);
|
||||
|
||||
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
|
||||
if (rc_idx != NOSTR_SUCCESS) {
|
||||
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
||||
@@ -524,19 +533,34 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
|
||||
char pubkey_hex[65];
|
||||
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc_pk != NOSTR_SUCCESS) {
|
||||
char errmsg[256];
|
||||
char errmsg[320];
|
||||
const char *desc = "unknown error";
|
||||
switch (rc_pk) {
|
||||
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
|
||||
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
|
||||
case -2002: desc = "index not in signer's whitelist"; break;
|
||||
case -3: desc = "crypto operation failed"; break;
|
||||
case NOSTR_ERROR_NIP46_AUTH_CHALLENGE:
|
||||
/* Device RPC codes 2010-2017: auth-related rejection.
|
||||
* Most commonly this means the kind-27235 caller auth
|
||||
* envelope was missing/rejected (the default caller
|
||||
* identity is installed automatically, but the device's
|
||||
* policy may still deny it). It can also mean the device
|
||||
* is requesting on-device approval (button press / TUI
|
||||
* confirm) for this operation. */
|
||||
desc = "auth rejected by n_signer (code -310). The caller "
|
||||
"auth envelope was missing or denied, or the device "
|
||||
"is requesting on-device approval. Confirm any "
|
||||
"prompt on the n_signer hardware, then click Sign "
|
||||
"In again";
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
snprintf(errmsg, sizeof(errmsg),
|
||||
"n_signer error: %s (code %d). Try a different key index.",
|
||||
desc, rc_pk);
|
||||
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
|
||||
nostr_signer_free(signer);
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return;
|
||||
}
|
||||
@@ -1016,17 +1040,24 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
|
||||
g_signal_connect(transport_combo, "changed",
|
||||
G_CALLBACK(on_transport_changed), box);
|
||||
|
||||
/* Nostr index spinner. */
|
||||
/* Nostr index spinner. The label shows the derived BIP-32 path
|
||||
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
|
||||
* spin button value, so they can see which key they are selecting. */
|
||||
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
|
||||
|
||||
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
|
||||
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
|
||||
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
|
||||
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
|
||||
|
||||
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
|
||||
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
|
||||
|
||||
/* Update the path label whenever the index value changes. */
|
||||
g_signal_connect(index_spin, "value-changed",
|
||||
G_CALLBACK(on_index_changed), index_label);
|
||||
|
||||
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
|
||||
gtk_widget_set_sensitive(hint, FALSE);
|
||||
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
||||
@@ -1040,9 +1071,23 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
|
||||
g_object_set_data(G_OBJECT(box), "service-box", service_box);
|
||||
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
|
||||
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
|
||||
g_object_set_data(G_OBJECT(box), "index-label", index_label);
|
||||
return box;
|
||||
}
|
||||
|
||||
/* Update the key-index path label when the spin button value changes.
|
||||
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
|
||||
* current N value substituted in. */
|
||||
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
|
||||
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
|
||||
GtkWidget *label = GTK_WIDGET(user_data);
|
||||
int idx = gtk_spin_button_get_value_as_int(sb);
|
||||
char text[96];
|
||||
snprintf(text, sizeof(text),
|
||||
"Key Index (m/44'/1237'/%d'/0/0):", idx);
|
||||
gtk_label_set_text(GTK_LABEL(label), text);
|
||||
}
|
||||
|
||||
/* ── Serial device enumeration callback ───────────────────────── */
|
||||
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
@@ -1092,7 +1137,7 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
|
||||
/* Create dialog without auto-responding buttons — we add custom
|
||||
* buttons so we control whether the dialog closes. */
|
||||
GtkWidget *dialog = gtk_dialog_new();
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser — Sign In");
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser " SB_VERSION);
|
||||
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
||||
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
|
||||
|
||||
820
src/main.c
820
src/main.c
File diff suppressed because it is too large
Load Diff
543
src/net_services.c
Normal file
543
src/net_services.c
Normal file
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
* net_services.c — unified Tor/FIPS discovery, ownership and supervision
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "net_services.h"
|
||||
#include "tor_control.h"
|
||||
#include "fips_control.h"
|
||||
#include "settings.h"
|
||||
#include "web_context.h"
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static net_service_t g_services[NET_SERVICE_COUNT];
|
||||
static gboolean g_initialized = FALSE;
|
||||
static gboolean g_shutting_down = FALSE;
|
||||
|
||||
static const char *service_name(net_service_type_t type) {
|
||||
return type == NET_SERVICE_TOR ? "tor" : "fips";
|
||||
}
|
||||
|
||||
static gboolean valid_type(net_service_type_t type) {
|
||||
return type >= NET_SERVICE_TOR && type < NET_SERVICE_COUNT;
|
||||
}
|
||||
|
||||
static void clear_error(net_service_t *service) {
|
||||
g_clear_pointer(&service->error_msg, g_free);
|
||||
}
|
||||
|
||||
static void fail_service(net_service_t *service, const char *message) {
|
||||
clear_error(service);
|
||||
service->error_msg = g_strdup(message ? message : "unknown error");
|
||||
service->state = SERVICE_FAILED;
|
||||
g_printerr("[net.service.%s] FAILED: %s\n", service_name(service->type),
|
||||
service->error_msg);
|
||||
if (service->type == NET_SERVICE_TOR) net_services_refresh_proxy();
|
||||
}
|
||||
|
||||
static char *expand_path(const char *path) {
|
||||
if (!path) return g_strdup("");
|
||||
if (path[0] == '~' && path[1] == '/')
|
||||
return g_build_filename(g_get_home_dir(), path + 2, NULL);
|
||||
return g_strdup(path);
|
||||
}
|
||||
|
||||
static int ensure_private_dir(const char *path, char *error, size_t error_size) {
|
||||
if (g_mkdir_with_parents(path, 0700) != 0) {
|
||||
snprintf(error, error_size, "cannot create %s: %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (chmod(path, 0700) != 0) {
|
||||
snprintf(error, error_size, "cannot secure %s: %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void net_services_refresh_proxy(void) {
|
||||
if (!g_initialized) return;
|
||||
/* Use the per-user context (web_context_get) instead of the default
|
||||
* context, so proxy settings apply to the active user's context.
|
||||
* Falls back to the default context if no per-user context has been
|
||||
* built yet (e.g. during early startup). See plans/webkit-data-isolation.md. */
|
||||
WebKitWebContext *ctx = web_context_get();
|
||||
if (ctx == NULL) ctx = webkit_web_context_get_default();
|
||||
if (ctx == NULL) return;
|
||||
WebKitWebsiteDataManager *dm = webkit_web_context_get_website_data_manager(ctx);
|
||||
if (dm == NULL) return;
|
||||
webkit_website_data_manager_set_network_proxy_settings(
|
||||
dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL);
|
||||
}
|
||||
|
||||
static gboolean pipe_log_cb(GIOChannel *channel, GIOCondition condition, gpointer data) {
|
||||
net_service_t *service = data;
|
||||
if (condition & (G_IO_IN | G_IO_PRI)) {
|
||||
gchar *line = NULL;
|
||||
gsize length = 0;
|
||||
GError *error = NULL;
|
||||
GIOStatus status = g_io_channel_read_line(channel, &line, &length, NULL, &error);
|
||||
if (status == G_IO_STATUS_NORMAL && line) {
|
||||
g_strchomp(line);
|
||||
if (line[0]) g_print("[net.service.%s] %s\n", service_name(service->type), line);
|
||||
}
|
||||
g_free(line);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
return (condition & (G_IO_HUP | G_IO_ERR | G_IO_NVAL)) == 0;
|
||||
}
|
||||
|
||||
static void close_channel(GIOChannel **channel, guint *watch) {
|
||||
if (*watch) { g_source_remove(*watch); *watch = 0; }
|
||||
if (*channel) {
|
||||
g_io_channel_shutdown(*channel, TRUE, NULL);
|
||||
g_io_channel_unref(*channel);
|
||||
*channel = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void close_control(net_service_t *service) {
|
||||
if (service->type == NET_SERVICE_TOR) tor_control_close(&service->control_fd);
|
||||
else fips_control_close(&service->control_fd);
|
||||
}
|
||||
|
||||
static gboolean kill_timeout_cb(gpointer data) {
|
||||
net_service_t *service = data;
|
||||
service->stop_timer = 0;
|
||||
if (service->pid > 0) {
|
||||
g_printerr("[net.service.%s] SIGTERM timeout; sending SIGKILL\n",
|
||||
service_name(service->type));
|
||||
kill(service->pid, SIGKILL);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void child_exit_cb(GPid pid, gint status, gpointer data) {
|
||||
net_service_t *service = data;
|
||||
gboolean expected = service->stop_requested || g_shutting_down;
|
||||
if (service->stop_timer) { g_source_remove(service->stop_timer); service->stop_timer = 0; }
|
||||
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
|
||||
close_control(service);
|
||||
close_channel(&service->stdout_channel, &service->stdout_watch);
|
||||
close_channel(&service->stderr_channel, &service->stderr_watch);
|
||||
service->child_watch = 0;
|
||||
service->pid = 0;
|
||||
g_spawn_close_pid(pid);
|
||||
if (expected) {
|
||||
service->state = SERVICE_EXITED;
|
||||
g_print("[net.service.%s] managed process exited\n", service_name(service->type));
|
||||
} else {
|
||||
char message[160];
|
||||
if (WIFEXITED(status))
|
||||
snprintf(message, sizeof(message), "process exited with status %d", WEXITSTATUS(status));
|
||||
else if (WIFSIGNALED(status))
|
||||
snprintf(message, sizeof(message), "process killed by signal %d", WTERMSIG(status));
|
||||
else snprintf(message, sizeof(message), "process exited unexpectedly");
|
||||
fail_service(service, message);
|
||||
}
|
||||
if (service->type == NET_SERVICE_FIPS) net_services_refresh_proxy();
|
||||
}
|
||||
|
||||
static int spawn_managed(net_service_t *service, gchar **argv, char *error, size_t error_size) {
|
||||
if (service->pid > 0 || service->child_watch != 0) {
|
||||
snprintf(error, error_size, "%s is already running", service_name(service->type));
|
||||
return -1;
|
||||
}
|
||||
GPid pid = 0;
|
||||
gint stdout_fd = -1, stderr_fd = -1;
|
||||
GError *gerror = NULL;
|
||||
service->state = SERVICE_STARTING;
|
||||
if (!g_spawn_async_with_pipes(NULL, argv, NULL,
|
||||
G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_SEARCH_PATH,
|
||||
NULL, NULL, &pid, NULL, &stdout_fd, &stderr_fd, &gerror)) {
|
||||
snprintf(error, error_size, "cannot start %s: %s", service_name(service->type),
|
||||
gerror ? gerror->message : "unknown error");
|
||||
g_clear_error(&gerror);
|
||||
return -1;
|
||||
}
|
||||
service->pid = pid;
|
||||
service->ownership = OWNERSHIP_MANAGED;
|
||||
service->stop_requested = FALSE;
|
||||
service->stdout_channel = g_io_channel_unix_new(stdout_fd);
|
||||
service->stderr_channel = g_io_channel_unix_new(stderr_fd);
|
||||
g_io_channel_set_close_on_unref(service->stdout_channel, TRUE);
|
||||
g_io_channel_set_close_on_unref(service->stderr_channel, TRUE);
|
||||
service->stdout_watch = g_io_add_watch(service->stdout_channel,
|
||||
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, pipe_log_cb, service);
|
||||
service->stderr_watch = g_io_add_watch(service->stderr_channel,
|
||||
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, pipe_log_cb, service);
|
||||
service->child_watch = g_child_watch_add(pid, child_exit_cb, service);
|
||||
service->state = SERVICE_BOOTSTRAPPING;
|
||||
g_print("[net.service.%s] spawned managed process pid=%d\n",
|
||||
service_name(service->type), (int)pid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static gboolean tor_poll_cb(gpointer data) {
|
||||
net_service_t *service = data;
|
||||
char error[256] = "";
|
||||
if (service->control_fd < 0) {
|
||||
service->control_fd = tor_control_connect(service->status.tor.control_endpoint,
|
||||
500, error, sizeof(error));
|
||||
if (service->control_fd < 0) return G_SOURCE_CONTINUE;
|
||||
if (tor_control_authenticate(service->control_fd,
|
||||
service->status.tor.cookie_path,
|
||||
error, sizeof(error)) != 0) {
|
||||
close_control(service);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
}
|
||||
int progress = 0;
|
||||
char summary[256] = "";
|
||||
if (tor_control_get_bootstrap(service->control_fd, &progress, summary,
|
||||
sizeof(summary), error, sizeof(error)) != 0) {
|
||||
close_control(service);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
service->status.tor.bootstrap_progress = progress;
|
||||
snprintf(service->status.tor.circuit_info,
|
||||
sizeof(service->status.tor.circuit_info), "%s", summary);
|
||||
if (progress >= 100) {
|
||||
service->state = SERVICE_READY;
|
||||
service->poll_timer = 0;
|
||||
g_print("[net.service.tor] bootstrap complete\n");
|
||||
net_services_refresh_proxy();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
g_print("[net.service.tor] bootstrap %d%%\n", progress);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static gboolean fips_poll_cb(gpointer data) {
|
||||
net_service_t *service = data;
|
||||
char error[256] = "";
|
||||
if (service->control_fd < 0) {
|
||||
service->control_fd = fips_control_connect(service->status.fips.control_socket,
|
||||
500, error, sizeof(error));
|
||||
if (service->control_fd < 0) return G_SOURCE_CONTINUE;
|
||||
}
|
||||
fips_status_t status;
|
||||
if (fips_control_show_status(service->control_fd, &status, error, sizeof(error)) != 0) {
|
||||
close_control(service);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
service->status.fips.peer_count = status.peer_count;
|
||||
snprintf(service->status.fips.node_npub, sizeof(service->status.fips.node_npub), "%s", status.npub);
|
||||
snprintf(service->status.fips.tree_state, sizeof(service->status.fips.tree_state), "%s", status.tree_state);
|
||||
snprintf(service->status.fips.tun_name, sizeof(service->status.fips.tun_name), "%s", status.tun_name);
|
||||
snprintf(service->status.fips.daemon_state, sizeof(service->status.fips.daemon_state), "%s", status.state);
|
||||
service->status.fips.tun_active = status.tun_active;
|
||||
if (service->state != SERVICE_READY) {
|
||||
service->state = SERVICE_READY;
|
||||
g_print("[net.service.fips] ready: node=%s peers=%d tun=%s\n",
|
||||
status.npub, status.peer_count, status.tun_name);
|
||||
net_services_refresh_proxy();
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static int attach_tor(net_service_t *service, const char *socks, const char *control) {
|
||||
service->state = SERVICE_ATTACHING;
|
||||
service->ownership = OWNERSHIP_ATTACHED;
|
||||
snprintf(service->status.tor.socks_endpoint, sizeof(service->status.tor.socks_endpoint), "%s", socks);
|
||||
if (control) snprintf(service->status.tor.control_endpoint,
|
||||
sizeof(service->status.tor.control_endpoint), "%s", control);
|
||||
service->state = SERVICE_READY;
|
||||
service->status.tor.bootstrap_progress = 100;
|
||||
g_print("[net.service.tor] attached to SOCKS endpoint %s\n", socks);
|
||||
net_services_refresh_proxy();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int start_managed_tor(net_service_t *service) {
|
||||
const browser_settings_t *settings = settings_get();
|
||||
char error[512] = "";
|
||||
char *root = expand_path(settings->tor_data_dir);
|
||||
char *data = g_build_filename(root, "data", NULL);
|
||||
char *torrc = g_build_filename(root, "torrc", NULL);
|
||||
char *socks = g_build_filename(root, "socks.sock", NULL);
|
||||
char *control = g_build_filename(root, "control.sock", NULL);
|
||||
char *cookie = g_build_filename(data, "control_auth_cookie", NULL);
|
||||
char *log = g_build_filename(root, "tor.log", NULL);
|
||||
if (ensure_private_dir(root, error, sizeof(error)) != 0 ||
|
||||
ensure_private_dir(data, error, sizeof(error)) != 0) {
|
||||
fail_service(service, error);
|
||||
goto failed;
|
||||
}
|
||||
unlink(socks);
|
||||
unlink(control);
|
||||
gchar *config = g_strdup_printf(
|
||||
"DataDirectory %s\nSocksPort unix:%s\nControlPort unix:%s\n"
|
||||
"CookieAuthentication 1\nCookieAuthFileGroupReadable 0\n"
|
||||
"Log notice file %s\nAvoidDiskWrites 1\n", data, socks, control, log);
|
||||
GError *gerror = NULL;
|
||||
if (!g_file_set_contents(torrc, config, -1, &gerror)) {
|
||||
snprintf(error, sizeof(error), "cannot write torrc: %s",
|
||||
gerror ? gerror->message : "unknown error");
|
||||
g_clear_error(&gerror);
|
||||
g_free(config);
|
||||
fail_service(service, error);
|
||||
goto failed;
|
||||
}
|
||||
g_free(config);
|
||||
chmod(torrc, 0600);
|
||||
g_free(service->binary_path); service->binary_path = g_strdup(settings->tor_binary_path);
|
||||
g_free(service->data_dir); service->data_dir = g_strdup(root);
|
||||
snprintf(service->status.tor.socks_endpoint, sizeof(service->status.tor.socks_endpoint),
|
||||
"socks5://unix:%s", socks);
|
||||
snprintf(service->status.tor.control_endpoint, sizeof(service->status.tor.control_endpoint),
|
||||
"unix:%s", control);
|
||||
snprintf(service->status.tor.cookie_path, sizeof(service->status.tor.cookie_path), "%s", cookie);
|
||||
gchar *argv[] = { (gchar *)settings->tor_binary_path, "-f", torrc, NULL };
|
||||
if (spawn_managed(service, argv, error, sizeof(error)) != 0) {
|
||||
fail_service(service, error);
|
||||
goto failed;
|
||||
}
|
||||
service->poll_timer = g_timeout_add_seconds(2, tor_poll_cb, service);
|
||||
g_free(root); g_free(data); g_free(torrc); g_free(socks); g_free(control); g_free(cookie); g_free(log);
|
||||
return 0;
|
||||
failed:
|
||||
g_free(root); g_free(data); g_free(torrc); g_free(socks); g_free(control); g_free(cookie); g_free(log);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static gboolean binary_has_net_admin(const char *binary) {
|
||||
if (geteuid() == 0) return TRUE;
|
||||
gchar *resolved = g_find_program_in_path(binary);
|
||||
if (!resolved) return FALSE;
|
||||
gchar *stdout_text = NULL;
|
||||
gint status = 0;
|
||||
gchar *argv[] = { "getcap", resolved, NULL };
|
||||
gboolean spawned = g_spawn_sync(NULL, argv, NULL, G_SPAWN_SEARCH_PATH,
|
||||
NULL, NULL, &stdout_text, NULL, &status, NULL);
|
||||
gboolean capable = spawned && WIFEXITED(status) && WEXITSTATUS(status) == 0 &&
|
||||
stdout_text && strstr(stdout_text, "cap_net_admin") != NULL;
|
||||
g_free(stdout_text);
|
||||
g_free(resolved);
|
||||
return capable;
|
||||
}
|
||||
|
||||
static int attach_fips(net_service_t *service, const char *socket_path) {
|
||||
char error[256] = "";
|
||||
service->state = SERVICE_ATTACHING;
|
||||
service->control_fd = fips_control_connect(socket_path, 700, error, sizeof(error));
|
||||
if (service->control_fd < 0) return -1;
|
||||
service->ownership = OWNERSHIP_ATTACHED;
|
||||
snprintf(service->status.fips.control_socket, sizeof(service->status.fips.control_socket), "%s", socket_path);
|
||||
if (!fips_poll_cb(service)) return -1;
|
||||
service->poll_timer = g_timeout_add_seconds(5, fips_poll_cb, service);
|
||||
g_print("[net.service.fips] attached to %s\n", socket_path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int start_managed_fips(net_service_t *service) {
|
||||
const browser_settings_t *settings = settings_get();
|
||||
char error[512] = "";
|
||||
if (!binary_has_net_admin(settings->fips_binary_path)) {
|
||||
fail_service(service, "FIPS binary not found or lacks CAP_NET_ADMIN");
|
||||
return -1;
|
||||
}
|
||||
char *root = expand_path(settings->fips_config_dir);
|
||||
char *config_path = g_build_filename(root, "fips.yaml", NULL);
|
||||
char *socket_path = g_build_filename(root, "control.sock", NULL);
|
||||
if (ensure_private_dir(root, error, sizeof(error)) != 0) {
|
||||
fail_service(service, error); goto failed;
|
||||
}
|
||||
unlink(socket_path);
|
||||
gchar *config = g_strdup_printf(
|
||||
"node:\n identity:\n persistent: true\n control:\n enabled: true\n"
|
||||
" socket_path: \"%s\"\ntun:\n device: fips0\ndns:\n enabled: true\n"
|
||||
" bind_addr: \"::1\"\n port: 5354\ntransports:\n"
|
||||
" - type: udp\n bind_addr: \"0.0.0.0:4242\"\n"
|
||||
" - type: tcp\n listen: \"0.0.0.0:4243\"\npeers: []\n", socket_path);
|
||||
GError *gerror = NULL;
|
||||
if (!g_file_set_contents(config_path, config, -1, &gerror)) {
|
||||
snprintf(error, sizeof(error), "cannot write fips.yaml: %s",
|
||||
gerror ? gerror->message : "unknown error");
|
||||
g_clear_error(&gerror); g_free(config); fail_service(service, error); goto failed;
|
||||
}
|
||||
g_free(config); chmod(config_path, 0600);
|
||||
g_free(service->binary_path); service->binary_path = g_strdup(settings->fips_binary_path);
|
||||
g_free(service->data_dir); service->data_dir = g_strdup(root);
|
||||
snprintf(service->status.fips.control_socket, sizeof(service->status.fips.control_socket), "%s", socket_path);
|
||||
gchar *argv[] = { (gchar *)settings->fips_binary_path, "--config", config_path, NULL };
|
||||
if (spawn_managed(service, argv, error, sizeof(error)) != 0) {
|
||||
fail_service(service, error); goto failed;
|
||||
}
|
||||
service->poll_timer = g_timeout_add_seconds(2, fips_poll_cb, service);
|
||||
g_free(root); g_free(config_path); g_free(socket_path);
|
||||
return 0;
|
||||
failed:
|
||||
g_free(root); g_free(config_path); g_free(socket_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int net_service_enable(net_service_type_t type) {
|
||||
if (!g_initialized || !valid_type(type)) return -1;
|
||||
net_service_t *service = &g_services[type];
|
||||
if (service->state == SERVICE_DISCOVERING || service->state == SERVICE_ATTACHING ||
|
||||
service->state == SERVICE_STARTING || service->state == SERVICE_BOOTSTRAPPING ||
|
||||
service->state == SERVICE_READY || service->state == SERVICE_STOPPING) return 0;
|
||||
clear_error(service);
|
||||
service->state = SERVICE_DISCOVERING;
|
||||
service->ownership = OWNERSHIP_NONE;
|
||||
const browser_settings_t *settings = settings_get();
|
||||
|
||||
if (type == NET_SERVICE_TOR) {
|
||||
net_services_refresh_proxy();
|
||||
gboolean allow_attach = strcmp(settings->tor_mode, "manage") != 0;
|
||||
gboolean allow_manage = strcmp(settings->tor_mode, "attach") != 0;
|
||||
if (allow_attach && settings->tor_attach_socks[0] &&
|
||||
tor_socks_endpoint_available(settings->tor_attach_socks, 500))
|
||||
return attach_tor(service, settings->tor_attach_socks,
|
||||
settings->tor_attach_control[0] ? settings->tor_attach_control : NULL);
|
||||
if (allow_attach && tor_control_endpoint_available("unix:/run/tor/control", 300) &&
|
||||
tor_socks_endpoint_available("127.0.0.1:9050", 300))
|
||||
return attach_tor(service, "socks5://127.0.0.1:9050", "unix:/run/tor/control");
|
||||
if (allow_attach && tor_control_endpoint_available("127.0.0.1:9051", 300) &&
|
||||
tor_socks_endpoint_available("127.0.0.1:9050", 300))
|
||||
return attach_tor(service, "socks5://127.0.0.1:9050", "127.0.0.1:9051");
|
||||
if (allow_attach && tor_socks_endpoint_available("127.0.0.1:9050", 300))
|
||||
return attach_tor(service, "socks5://127.0.0.1:9050", NULL);
|
||||
if (allow_manage) return start_managed_tor(service);
|
||||
fail_service(service, "no attachable Tor instance found");
|
||||
return -1;
|
||||
}
|
||||
|
||||
gboolean allow_attach = strcmp(settings->fips_mode, "manage") != 0;
|
||||
gboolean allow_manage = strcmp(settings->fips_mode, "attach") != 0;
|
||||
if (allow_attach) {
|
||||
if (settings->fips_control_socket[0] && attach_fips(service, settings->fips_control_socket) == 0) return 0;
|
||||
const char *xdg = g_getenv("XDG_RUNTIME_DIR");
|
||||
char *xdg_path = xdg && xdg[0] ? g_build_filename(xdg, "fips", "control.sock", NULL) : NULL;
|
||||
const char *paths[] = { "/run/fips/control.sock", xdg_path, "/tmp/fips-control.sock", NULL };
|
||||
for (int i = 0; paths[i]; i++) {
|
||||
if (paths[i] && fips_control_socket_available(paths[i], 300) && attach_fips(service, paths[i]) == 0) {
|
||||
g_free(xdg_path); return 0;
|
||||
}
|
||||
}
|
||||
g_free(xdg_path);
|
||||
}
|
||||
if (allow_manage) return start_managed_fips(service);
|
||||
fail_service(service, "no attachable FIPS instance found");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int net_service_disable(net_service_type_t type) {
|
||||
if (!g_initialized || !valid_type(type)) return -1;
|
||||
net_service_t *service = &g_services[type];
|
||||
if (service->state == SERVICE_DISABLED || service->state == SERVICE_EXITED) {
|
||||
service->state = SERVICE_DISABLED;
|
||||
if (type == NET_SERVICE_TOR) net_services_refresh_proxy();
|
||||
return 0;
|
||||
}
|
||||
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
|
||||
if (service->ownership == OWNERSHIP_ATTACHED) {
|
||||
close_control(service);
|
||||
service->ownership = OWNERSHIP_NONE;
|
||||
service->state = SERVICE_DISABLED;
|
||||
g_print("[net.service.%s] detached (system service left running)\n", service_name(type));
|
||||
} else if (service->ownership == OWNERSHIP_MANAGED && service->pid > 0) {
|
||||
service->state = SERVICE_STOPPING;
|
||||
service->stop_requested = TRUE;
|
||||
if (type == NET_SERVICE_TOR && service->control_fd >= 0)
|
||||
tor_control_signal_term(service->control_fd, NULL, 0);
|
||||
else kill(service->pid, SIGTERM);
|
||||
if (!service->stop_timer)
|
||||
service->stop_timer = g_timeout_add_seconds(5, kill_timeout_cb, service);
|
||||
} else {
|
||||
close_control(service);
|
||||
service->ownership = OWNERSHIP_NONE;
|
||||
service->state = SERVICE_DISABLED;
|
||||
}
|
||||
if (type == NET_SERVICE_TOR) net_services_refresh_proxy();
|
||||
else net_services_refresh_proxy();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int net_service_restart(net_service_type_t type) {
|
||||
if (!g_initialized || !valid_type(type)) return -1;
|
||||
net_service_t *service = &g_services[type];
|
||||
if (service->ownership == OWNERSHIP_ATTACHED) return 0;
|
||||
if (service->pid > 0) {
|
||||
net_service_disable(type);
|
||||
return -1; /* Explicit enable after asynchronous exit prevents duplicates. */
|
||||
}
|
||||
service->state = SERVICE_EXITED;
|
||||
return net_service_enable(type);
|
||||
}
|
||||
|
||||
const net_service_t *net_service_get_status(net_service_type_t type) {
|
||||
return valid_type(type) ? &g_services[type] : NULL;
|
||||
}
|
||||
|
||||
gboolean net_service_is_enabled(net_service_type_t type) {
|
||||
if (!valid_type(type)) return FALSE;
|
||||
const browser_settings_t *settings = settings_get();
|
||||
return type == NET_SERVICE_TOR ? settings->tor_enabled : settings->fips_enabled;
|
||||
}
|
||||
|
||||
gboolean net_service_is_ready(net_service_type_t type) {
|
||||
return valid_type(type) && g_services[type].state == SERVICE_READY;
|
||||
}
|
||||
|
||||
void net_services_init(void) {
|
||||
if (g_initialized) return;
|
||||
memset(g_services, 0, sizeof(g_services));
|
||||
for (int i = 0; i < NET_SERVICE_COUNT; i++) {
|
||||
g_services[i].type = (net_service_type_t)i;
|
||||
g_services[i].state = SERVICE_DISABLED;
|
||||
g_services[i].ownership = OWNERSHIP_NONE;
|
||||
g_services[i].control_fd = -1;
|
||||
}
|
||||
g_initialized = TRUE;
|
||||
g_shutting_down = FALSE;
|
||||
g_print("[net.service] initialized (Tor=%s, FIPS=%s)\n",
|
||||
settings_get()->tor_enabled ? "enabled" : "disabled",
|
||||
settings_get()->fips_enabled ? "enabled" : "disabled");
|
||||
if (settings_get()->tor_enabled) {
|
||||
net_services_refresh_proxy();
|
||||
net_service_enable(NET_SERVICE_TOR);
|
||||
}
|
||||
if (settings_get()->fips_enabled) net_service_enable(NET_SERVICE_FIPS);
|
||||
}
|
||||
|
||||
void net_services_shutdown(void) {
|
||||
if (!g_initialized) return;
|
||||
g_shutting_down = TRUE;
|
||||
g_print("[net.service] shutting down\n");
|
||||
for (int i = 0; i < NET_SERVICE_COUNT; i++) net_service_disable((net_service_type_t)i);
|
||||
|
||||
gint64 deadline = g_get_monotonic_time() + 5 * G_TIME_SPAN_SECOND;
|
||||
gboolean children;
|
||||
do {
|
||||
children = FALSE;
|
||||
for (int i = 0; i < NET_SERVICE_COUNT; i++)
|
||||
if (g_services[i].pid > 0) children = TRUE;
|
||||
if (children) {
|
||||
while (g_main_context_pending(NULL)) g_main_context_iteration(NULL, FALSE);
|
||||
g_usleep(10000);
|
||||
}
|
||||
} while (children && g_get_monotonic_time() < deadline);
|
||||
for (int i = 0; i < NET_SERVICE_COUNT; i++) {
|
||||
net_service_t *service = &g_services[i];
|
||||
if (service->pid > 0) kill(service->pid, SIGKILL);
|
||||
if (service->stop_timer) { g_source_remove(service->stop_timer); service->stop_timer = 0; }
|
||||
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
|
||||
close_control(service);
|
||||
g_clear_pointer(&service->binary_path, g_free);
|
||||
g_clear_pointer(&service->data_dir, g_free);
|
||||
clear_error(service);
|
||||
}
|
||||
g_initialized = FALSE;
|
||||
}
|
||||
96
src/net_services.h
Normal file
96
src/net_services.h
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* net_services.h — Tor and FIPS network-service lifecycle management
|
||||
*/
|
||||
|
||||
#ifndef NET_SERVICES_H
|
||||
#define NET_SERVICES_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
NET_SERVICE_TOR,
|
||||
NET_SERVICE_FIPS,
|
||||
NET_SERVICE_COUNT
|
||||
} net_service_type_t;
|
||||
|
||||
typedef enum {
|
||||
SERVICE_DISABLED,
|
||||
SERVICE_DISCOVERING,
|
||||
SERVICE_ATTACHING,
|
||||
SERVICE_STARTING,
|
||||
SERVICE_BOOTSTRAPPING,
|
||||
SERVICE_READY,
|
||||
SERVICE_STOPPING,
|
||||
SERVICE_EXITED,
|
||||
SERVICE_FAILED,
|
||||
} service_state_t;
|
||||
|
||||
typedef enum {
|
||||
OWNERSHIP_NONE,
|
||||
OWNERSHIP_ATTACHED,
|
||||
OWNERSHIP_MANAGED,
|
||||
} service_ownership_t;
|
||||
|
||||
typedef struct {
|
||||
net_service_type_t type;
|
||||
service_state_t state;
|
||||
service_ownership_t ownership;
|
||||
GPid pid;
|
||||
char *binary_path;
|
||||
char *data_dir;
|
||||
int control_fd;
|
||||
char *error_msg;
|
||||
guint child_watch;
|
||||
guint poll_timer;
|
||||
guint stop_timer;
|
||||
guint stdout_watch;
|
||||
guint stderr_watch;
|
||||
GIOChannel *stdout_channel;
|
||||
GIOChannel *stderr_channel;
|
||||
gboolean stop_requested;
|
||||
union {
|
||||
struct {
|
||||
int bootstrap_progress;
|
||||
char circuit_info[256];
|
||||
char socks_endpoint[512];
|
||||
char control_endpoint[512];
|
||||
char cookie_path[512];
|
||||
} tor;
|
||||
struct {
|
||||
int peer_count;
|
||||
char node_npub[80];
|
||||
char tree_state[32];
|
||||
gboolean tun_active;
|
||||
char tun_name[16];
|
||||
char daemon_state[32];
|
||||
char control_socket[512];
|
||||
} fips;
|
||||
} status;
|
||||
} net_service_t;
|
||||
|
||||
/* Initialize after the shared WebKit context and login are available. */
|
||||
void net_services_init(void);
|
||||
|
||||
int net_service_enable(net_service_type_t type);
|
||||
int net_service_disable(net_service_type_t type);
|
||||
int net_service_restart(net_service_type_t type);
|
||||
|
||||
const net_service_t *net_service_get_status(net_service_type_t type);
|
||||
gboolean net_service_is_enabled(net_service_type_t type);
|
||||
gboolean net_service_is_ready(net_service_type_t type);
|
||||
|
||||
/* Rebuild Tor's proxy ignore-host list after FIPS readiness changes. */
|
||||
void net_services_refresh_proxy(void);
|
||||
|
||||
/* Stop owned children and detach from system services. */
|
||||
void net_services_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NET_SERVICES_H */
|
||||
3661
src/nostr_bridge.c
3661
src/nostr_bridge.c
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@ static const char *NOSTR_SHIM_JS =
|
||||
"\n"
|
||||
" function bridgeCall(method, bodyObj) {\n"
|
||||
" var url = BRIDGE_BASE + method;\n"
|
||||
" var opts = {};\n"
|
||||
"\n"
|
||||
" if (bodyObj !== undefined && bodyObj !== null) {\n"
|
||||
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
|
||||
@@ -36,20 +35,37 @@ static const char *NOSTR_SHIM_JS =
|
||||
" url += '?body=' + encodeURIComponent(bodyJson);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" return fetch(url).then(function(resp) {\n"
|
||||
" return resp.text();\n"
|
||||
" }).then(function(text) {\n"
|
||||
" var data;\n"
|
||||
" /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR\n"
|
||||
" * enforce CORS on custom schemes even when registered as secure, but\n"
|
||||
" * synchronous XHR to a secure custom scheme works without CORS headers.\n"
|
||||
" * The bridge is local (sovereign:// URI scheme handler in C), so the\n"
|
||||
" * call is effectively instant. */\n"
|
||||
" return new Promise(function(resolve, reject) {\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(text);\n"
|
||||
" var xhr = new XMLHttpRequest();\n"
|
||||
" xhr.open('GET', url, false); /* synchronous */\n"
|
||||
" xhr.send();\n"
|
||||
" var text = xhr.responseText;\n"
|
||||
" if (!text) {\n"
|
||||
" reject(new Error('Bridge returned empty response'));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" var data;\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(text);\n"
|
||||
" } catch(e) {\n"
|
||||
" reject(new Error('Bridge returned invalid JSON: ' + text));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (data.error !== undefined) {\n"
|
||||
" var msg = data.message || ('Error code ' + data.error);\n"
|
||||
" reject(new Error(msg));\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" resolve(data);\n"
|
||||
" } catch(e) {\n"
|
||||
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
|
||||
" reject(new Error('Bridge request failed: ' + e.message));\n"
|
||||
" }\n"
|
||||
" if (data.error !== undefined) {\n"
|
||||
" var msg = data.message || ('Error code ' + data.error);\n"
|
||||
" throw new Error(msg);\n"
|
||||
" }\n"
|
||||
" return data;\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
@@ -112,6 +128,39 @@ static const char *NOSTR_SHIM_JS =
|
||||
" }\n"
|
||||
"})();\n";
|
||||
|
||||
/* Wheel-scroll workaround for WebKitGTK.
|
||||
*
|
||||
* On some pages (e.g. jumble.social) WebKitGTK dispatches JS wheel events
|
||||
* but does not perform the native scroll action — so the scrollbar works
|
||||
* (native GDK scroll) but the mouse wheel doesn't scroll the page. This
|
||||
* script installs a non-passive wheel listener on window that, when the
|
||||
* page's main scroll container is the document (the common case), calls
|
||||
* preventDefault() and manually scrolls by the wheel delta. It only
|
||||
* intervenes when the target is the document/body/html or an element with
|
||||
* no own scroll (overflow: visible), so nested scroll containers (which
|
||||
* WebKitGTK handles correctly) are not broken. */
|
||||
static const char *WHEEL_SCROLL_JS =
|
||||
"(function(){\n"
|
||||
" 'use strict';\n"
|
||||
" function isMainScrollTarget(el){\n"
|
||||
" if(!el) return false;\n"
|
||||
" if(el===document||el===document.documentElement||el===document.body) return true;\n"
|
||||
" var cs=getComputedStyle(el);\n"
|
||||
" return (cs.overflowY==='visible'||cs.overflowY==='') && el.scrollHeight<=el.clientHeight;\n"
|
||||
" }\n"
|
||||
" window.addEventListener('wheel',function(e){\n"
|
||||
" if(e.defaultPrevented) return;\n"
|
||||
" if(!isMainScrollTarget(e.target)) return;\n"
|
||||
" var html=document.documentElement;\n"
|
||||
" if(html.scrollHeight<=html.clientHeight) return;\n"
|
||||
" e.preventDefault();\n"
|
||||
" var dy=e.deltaY;\n"
|
||||
" if(e.deltaMode===1) dy*=40;\n"
|
||||
" else if(e.deltaMode===2) dy*=html.clientHeight;\n"
|
||||
" window.scrollBy(0,dy);\n"
|
||||
" },{capture:false,passive:false});\n"
|
||||
"})();\n";
|
||||
|
||||
void nostr_inject_setup(WebKitWebView *webview) {
|
||||
WebKitUserContentManager *manager =
|
||||
webkit_web_view_get_user_content_manager(webview);
|
||||
@@ -132,5 +181,14 @@ void nostr_inject_setup(WebKitWebView *webview) {
|
||||
/* The manager takes ownership of the script. */
|
||||
webkit_user_script_unref(script);
|
||||
|
||||
g_print("[inject] window.nostr shim installed\n");
|
||||
/* Wheel-scroll workaround — see WHEEL_SCROLL_JS comment above. */
|
||||
WebKitUserScript *wheel = webkit_user_script_new(
|
||||
WHEEL_SCROLL_JS,
|
||||
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, wheel);
|
||||
webkit_user_script_unref(wheel);
|
||||
}
|
||||
|
||||
412
src/nostr_scheme.c
Normal file
412
src/nostr_scheme.c
Normal file
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* nostr_scheme.c — asynchronous relay-backed WebKitGTK handler for nostr://
|
||||
*/
|
||||
|
||||
#include "nostr_scheme.h"
|
||||
|
||||
#include "db.h"
|
||||
#include "nostr_url.h"
|
||||
#include "relay_fetch.h"
|
||||
#include "settings.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define MAX_FETCH_RELAYS 8
|
||||
#define FETCH_TIMEOUT_SECONDS 8
|
||||
#define FETCH_EVENT_LIMIT 20
|
||||
#define NIP11_MAX_BYTES (1024 * 1024)
|
||||
|
||||
extern const char *app_get_pubkey_hex(void);
|
||||
|
||||
typedef struct {
|
||||
WebKitURISchemeRequest *request;
|
||||
char *entity;
|
||||
char *bootstrap_relays;
|
||||
char *user_pubkey;
|
||||
nostr_decoded_entity_t decoded;
|
||||
GPtrArray *entity_relays;
|
||||
GPtrArray *user_relays;
|
||||
GPtrArray *default_relays;
|
||||
GPtrArray *attempted_relays;
|
||||
cJSON *events;
|
||||
cJSON *errors;
|
||||
cJSON *nip11;
|
||||
gboolean timed_out;
|
||||
gint64 started_us;
|
||||
char *response_json;
|
||||
} scheme_job_t;
|
||||
|
||||
typedef struct {
|
||||
GString *body;
|
||||
gboolean overflow;
|
||||
} curl_body_t;
|
||||
|
||||
static gboolean valid_relay_url(const char *url) {
|
||||
return url && (g_str_has_prefix(url, "wss://") ||
|
||||
g_str_has_prefix(url, "ws://"));
|
||||
}
|
||||
|
||||
static gboolean array_contains(GPtrArray *array, const char *url) {
|
||||
for (guint i = 0; array && i < array->len; i++)
|
||||
if (!g_ascii_strcasecmp(g_ptr_array_index(array, i), url)) return TRUE;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void add_unique_relay(GPtrArray *array, const char *url) {
|
||||
if (!array || !valid_relay_url(url) || array_contains(array, url)) return;
|
||||
char *copy = g_strdup(url);
|
||||
g_strstrip(copy);
|
||||
gsize len = strlen(copy);
|
||||
while (len > 0 && copy[len - 1] == '/') copy[--len] = '\0';
|
||||
if (*copy && !array_contains(array, copy)) g_ptr_array_add(array, copy);
|
||||
else g_free(copy);
|
||||
}
|
||||
|
||||
static void parse_bootstrap_relays(scheme_job_t *job) {
|
||||
gchar **lines = g_strsplit_set(job->bootstrap_relays ? job->bootstrap_relays : "",
|
||||
"\r\n", -1);
|
||||
for (gchar **line = lines; line && *line; line++) {
|
||||
g_strstrip(*line);
|
||||
add_unique_relay(job->default_relays, *line);
|
||||
}
|
||||
g_strfreev(lines);
|
||||
}
|
||||
|
||||
static void parse_user_relays(scheme_job_t *job) {
|
||||
if (!job->user_pubkey || !*job->user_pubkey) return;
|
||||
cJSON *event = db_get_latest_event(job->user_pubkey, 10002);
|
||||
if (!event) return;
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
cJSON *tag = NULL;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *name = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *url = cJSON_GetArrayItem(tag, 1);
|
||||
cJSON *marker = cJSON_GetArrayItem(tag, 2);
|
||||
if (!cJSON_IsString(name) || strcmp(name->valuestring, "r") ||
|
||||
!cJSON_IsString(url)) continue;
|
||||
/* NIP-65: no marker means both; "read" is readable; "write" is not. */
|
||||
if (cJSON_IsString(marker) && strcmp(marker->valuestring, "read")) continue;
|
||||
add_unique_relay(job->user_relays, url->valuestring);
|
||||
}
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
static GPtrArray *fallback_relays(scheme_job_t *job) {
|
||||
return job->user_relays->len ? job->user_relays : job->default_relays;
|
||||
}
|
||||
|
||||
static void record_attempted(scheme_job_t *job, GPtrArray *relays) {
|
||||
for (guint i = 0; i < relays->len && job->attempted_relays->len < MAX_FETCH_RELAYS; i++)
|
||||
add_unique_relay(job->attempted_relays, g_ptr_array_index(relays, i));
|
||||
}
|
||||
|
||||
static cJSON *build_filter(const scheme_job_t *job) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
if (job->decoded.type == NOSTR_ENTITY_NPUB ||
|
||||
job->decoded.type == NOSTR_ENTITY_NPROFILE) {
|
||||
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
|
||||
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
|
||||
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
g_free(pubkey);
|
||||
} else if (job->decoded.type == NOSTR_ENTITY_NOTE ||
|
||||
job->decoded.type == NOSTR_ENTITY_NEVENT) {
|
||||
char *id = nostr_hex_encode32(job->decoded.event_id);
|
||||
cJSON *ids = cJSON_AddArrayToObject(filter, "ids");
|
||||
cJSON_AddItemToArray(ids, cJSON_CreateString(id));
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
g_free(id);
|
||||
} else if (job->decoded.type == NOSTR_ENTITY_NADDR) {
|
||||
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
|
||||
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
|
||||
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(job->decoded.kind));
|
||||
cJSON *d_tags = cJSON_AddArrayToObject(filter, "#d");
|
||||
cJSON_AddItemToArray(d_tags, cJSON_CreateString(job->decoded.identifier));
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
g_free(pubkey);
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
static int query_relay_stage(scheme_job_t *job, GPtrArray *relays) {
|
||||
if (!relays || !relays->len || job->attempted_relays->len >= MAX_FETCH_RELAYS) return 0;
|
||||
const char *urls[MAX_FETCH_RELAYS];
|
||||
int count = 0;
|
||||
int remaining = MAX_FETCH_RELAYS - (int)job->attempted_relays->len;
|
||||
for (guint i = 0; i < relays->len && count < remaining; i++) {
|
||||
const char *url = g_ptr_array_index(relays, i);
|
||||
if (!array_contains(job->attempted_relays, url)) urls[count++] = url;
|
||||
}
|
||||
if (!count) return 0;
|
||||
record_attempted(job, relays);
|
||||
|
||||
cJSON *filter = build_filter(job);
|
||||
int result_count = 0;
|
||||
cJSON **results = relay_query_synchronized(
|
||||
urls, count, filter, RELAY_QUERY_ALL_RESULTS, &result_count,
|
||||
FETCH_TIMEOUT_SECONDS, NULL, NULL, 0, NULL);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (!results || result_count <= 0) {
|
||||
if (results) free(results);
|
||||
cJSON *diag = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(diag, "stage", relays == job->entity_relays
|
||||
? "entity_hints" : "fallback");
|
||||
cJSON_AddStringToObject(diag, "message",
|
||||
"No events returned before relay query completed");
|
||||
cJSON_AddItemToArray(job->errors, diag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
for (int i = 0; i < result_count && added < FETCH_EVENT_LIMIT; i++) {
|
||||
if (!results[i]) continue;
|
||||
cJSON_AddItemToArray(job->events, results[i]);
|
||||
results[i] = NULL;
|
||||
added++;
|
||||
}
|
||||
for (int i = 0; i < result_count; i++) if (results[i]) cJSON_Delete(results[i]);
|
||||
free(results);
|
||||
return added;
|
||||
}
|
||||
|
||||
static size_t curl_write_bounded(void *data, size_t size, size_t nmemb, void *user) {
|
||||
curl_body_t *body = user;
|
||||
size_t bytes = size * nmemb;
|
||||
if (body->body->len + bytes > NIP11_MAX_BYTES) {
|
||||
body->overflow = TRUE;
|
||||
return 0;
|
||||
}
|
||||
g_string_append_len(body->body, data, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void fetch_nip11(scheme_job_t *job) {
|
||||
char *http_url = NULL;
|
||||
if (g_str_has_prefix(job->decoded.relay_url, "wss://"))
|
||||
http_url = g_strdup_printf("https://%s", job->decoded.relay_url + 6);
|
||||
else if (g_str_has_prefix(job->decoded.relay_url, "ws://"))
|
||||
http_url = g_strdup_printf("http://%s", job->decoded.relay_url + 5);
|
||||
if (!http_url) return;
|
||||
|
||||
CURL *curl = curl_easy_init();
|
||||
curl_body_t body = { g_string_new(NULL), FALSE };
|
||||
struct curl_slist *headers = curl_slist_append(NULL, "Accept: application/nostr+json");
|
||||
curl_easy_setopt(curl, CURLOPT_URL, http_url);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_bounded);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)FETCH_TIMEOUT_SECONDS);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 4L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/nostr-uri");
|
||||
CURLcode result = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
if (result == CURLE_OK && status >= 200 && status < 300)
|
||||
job->nip11 = cJSON_ParseWithLength(body.body->str, body.body->len);
|
||||
if (!job->nip11) {
|
||||
cJSON *diag = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(diag, "stage", "nip11");
|
||||
cJSON_AddStringToObject(diag, "relay", job->decoded.relay_url);
|
||||
cJSON_AddNumberToObject(diag, "http_status", status);
|
||||
cJSON_AddStringToObject(diag, "message", body.overflow
|
||||
? "NIP-11 response exceeded size limit"
|
||||
: (result == CURLE_OK ? "NIP-11 response was not valid JSON"
|
||||
: curl_easy_strerror(result)));
|
||||
cJSON_AddItemToArray(job->errors, diag);
|
||||
if (result == CURLE_OPERATION_TIMEDOUT) job->timed_out = TRUE;
|
||||
}
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
g_string_free(body.body, TRUE);
|
||||
g_free(http_url);
|
||||
}
|
||||
|
||||
static void build_response(scheme_job_t *job) {
|
||||
cJSON *response = NULL;
|
||||
int event_count = cJSON_GetArraySize(job->events);
|
||||
|
||||
if (event_count == 1) {
|
||||
response = cJSON_DetachItemFromArray(job->events, 0);
|
||||
} else if (event_count > 1) {
|
||||
response = job->events;
|
||||
job->events = NULL;
|
||||
} else if (job->decoded.type == NOSTR_ENTITY_NRELAY && job->nip11) {
|
||||
response = job->nip11;
|
||||
job->nip11 = NULL;
|
||||
} else {
|
||||
response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "error", "No events found");
|
||||
cJSON_AddStringToObject(response, "entity", job->entity);
|
||||
}
|
||||
|
||||
job->response_json = cJSON_PrintUnformatted(response);
|
||||
cJSON_Delete(response);
|
||||
}
|
||||
|
||||
static gpointer scheme_worker(gpointer data) {
|
||||
scheme_job_t *job = data;
|
||||
parse_bootstrap_relays(job);
|
||||
parse_user_relays(job);
|
||||
for (guint i = 0; i < job->decoded.relay_hints->len; i++)
|
||||
add_unique_relay(job->entity_relays,
|
||||
g_ptr_array_index(job->decoded.relay_hints, i));
|
||||
|
||||
if (job->decoded.type == NOSTR_ENTITY_NRELAY) {
|
||||
fetch_nip11(job);
|
||||
} else {
|
||||
int found = 0;
|
||||
if (job->entity_relays->len) found = query_relay_stage(job, job->entity_relays);
|
||||
if (!found) query_relay_stage(job, fallback_relays(job));
|
||||
if (!job->attempted_relays->len) {
|
||||
cJSON *diag = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(diag, "stage", "relay_selection");
|
||||
cJSON_AddStringToObject(diag, "message", "No usable relay URLs available");
|
||||
cJSON_AddItemToArray(job->errors, diag);
|
||||
}
|
||||
}
|
||||
build_response(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
static void scheme_job_free(scheme_job_t *job) {
|
||||
if (!job) return;
|
||||
g_clear_object(&job->request);
|
||||
g_free(job->entity);
|
||||
g_free(job->bootstrap_relays);
|
||||
g_free(job->user_pubkey);
|
||||
g_free(job->response_json);
|
||||
nostr_decoded_entity_clear(&job->decoded);
|
||||
g_ptr_array_unref(job->entity_relays);
|
||||
g_ptr_array_unref(job->user_relays);
|
||||
g_ptr_array_unref(job->default_relays);
|
||||
g_ptr_array_unref(job->attempted_relays);
|
||||
if (job->events) cJSON_Delete(job->events);
|
||||
if (job->errors) cJSON_Delete(job->errors);
|
||||
if (job->nip11) cJSON_Delete(job->nip11);
|
||||
g_free(job);
|
||||
}
|
||||
|
||||
static void scheme_worker_done(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)source; (void)data;
|
||||
GTask *task = G_TASK(result);
|
||||
scheme_job_t *job = g_task_propagate_pointer(task, NULL);
|
||||
gsize len = strlen(job->response_json);
|
||||
GInputStream *stream = g_memory_input_stream_new_from_data(
|
||||
g_memdup2(job->response_json, len), len, g_free);
|
||||
webkit_uri_scheme_request_finish(job->request, stream, len, "application/json");
|
||||
g_object_unref(stream);
|
||||
scheme_job_free(job);
|
||||
}
|
||||
|
||||
static void task_thread(GTask *task, gpointer source, gpointer task_data,
|
||||
GCancellable *cancel) {
|
||||
(void)source; (void)cancel;
|
||||
g_task_return_pointer(task, scheme_worker(task_data), NULL);
|
||||
}
|
||||
|
||||
static char *extract_entity(const char *uri) {
|
||||
const char *start = nostr_url_entity(uri);
|
||||
if (!start) return NULL;
|
||||
while (*start == '/') start++;
|
||||
const char *end = start;
|
||||
while (*end && *end != '/' && *end != '?' && *end != '#') end++;
|
||||
return end == start ? NULL : g_strndup(start, end - start);
|
||||
}
|
||||
|
||||
static void respond_json(WebKitURISchemeRequest *request, char *json) {
|
||||
gsize len = strlen(json);
|
||||
GInputStream *stream = g_memory_input_stream_new_from_data(json, len, free);
|
||||
webkit_uri_scheme_request_finish(request, stream, len, "application/json");
|
||||
g_object_unref(stream);
|
||||
}
|
||||
|
||||
static void respond_error(WebKitURISchemeRequest *request, const char *entity,
|
||||
const char *message) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "error", message);
|
||||
if (entity) cJSON_AddStringToObject(root, "entity", entity);
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
}
|
||||
|
||||
static void respond_helper_apps(WebKitURISchemeRequest *request, const char *config) {
|
||||
cJSON *apps = cJSON_CreateArray();
|
||||
gchar **configured = g_strsplit(config ? config : "", ",", -1);
|
||||
for (gchar **item = configured; item && *item; item++) {
|
||||
gchar **parts = g_strsplit(*item, "|", 2);
|
||||
if (parts[0] && *parts[0] && parts[1] && *parts[1]) {
|
||||
cJSON *app = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(app, "name", parts[0]);
|
||||
cJSON_AddStringToObject(app, "url_template", parts[1]);
|
||||
cJSON_AddItemToArray(apps, app);
|
||||
}
|
||||
g_strfreev(parts);
|
||||
}
|
||||
g_strfreev(configured);
|
||||
char *json = cJSON_PrintUnformatted(apps);
|
||||
cJSON_Delete(apps);
|
||||
respond_json(request, json);
|
||||
}
|
||||
|
||||
static void on_nostr_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
|
||||
(void)user_data;
|
||||
const char *uri = webkit_uri_scheme_request_get_uri(request);
|
||||
char *entity = extract_entity(uri);
|
||||
const browser_settings_t *settings = settings_get();
|
||||
const char *path = entity ? strstr(uri + strlen("nostr://"), "/") : NULL;
|
||||
if ((entity && strcmp(entity, "helper-apps") == 0) ||
|
||||
(path && g_str_has_prefix(path, "/helper-apps") &&
|
||||
(path[12] == '\0' || path[12] == '?' || path[12] == '#'))) {
|
||||
respond_helper_apps(request, settings->nostr_helper_apps);
|
||||
g_free(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
nostr_decoded_entity_t decoded;
|
||||
GError *error = NULL;
|
||||
if (!entity || !nostr_url_decode(entity, &decoded, &error)) {
|
||||
respond_error(request, entity, error ? error->message :
|
||||
"Invalid or unsupported Nostr entity");
|
||||
g_clear_error(&error);
|
||||
g_free(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
scheme_job_t *job = g_new0(scheme_job_t, 1);
|
||||
job->request = g_object_ref(request);
|
||||
job->entity = entity;
|
||||
job->bootstrap_relays = g_strdup(settings->bootstrap_relays);
|
||||
const char *pubkey = app_get_pubkey_hex();
|
||||
job->user_pubkey = pubkey && *pubkey ? g_strdup(pubkey) : NULL;
|
||||
job->decoded = decoded;
|
||||
job->entity_relays = g_ptr_array_new_with_free_func(g_free);
|
||||
job->user_relays = g_ptr_array_new_with_free_func(g_free);
|
||||
job->default_relays = g_ptr_array_new_with_free_func(g_free);
|
||||
job->attempted_relays = g_ptr_array_new_with_free_func(g_free);
|
||||
job->events = cJSON_CreateArray();
|
||||
job->errors = cJSON_CreateArray();
|
||||
job->started_us = g_get_monotonic_time();
|
||||
|
||||
GTask *task = g_task_new(NULL, NULL, scheme_worker_done, NULL);
|
||||
g_task_set_task_data(task, job, NULL);
|
||||
g_task_run_in_thread(task, task_thread);
|
||||
g_object_unref(task);
|
||||
}
|
||||
|
||||
void nostr_scheme_register(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
webkit_web_context_register_uri_scheme(ctx, "nostr", on_nostr_scheme, NULL, NULL);
|
||||
}
|
||||
20
src/nostr_scheme.h
Normal file
20
src/nostr_scheme.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* nostr_scheme.h — WebKitGTK nostr:// URI scheme handler
|
||||
*/
|
||||
|
||||
#ifndef NOSTR_SCHEME_H
|
||||
#define NOSTR_SCHEME_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void nostr_scheme_register(WebKitWebContext *ctx);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NOSTR_SCHEME_H */
|
||||
327
src/nostr_url.c
Normal file
327
src/nostr_url.c
Normal file
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* nostr_url.c — validated NIP-19 decoding and Nostr URI normalization
|
||||
*/
|
||||
|
||||
#include "nostr_url.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#define NOSTR_URL_ERROR nostr_url_error_quark()
|
||||
#define MAX_DECODED_BYTES 4096
|
||||
#define MAX_RELAY_HINTS 32
|
||||
|
||||
typedef enum {
|
||||
NOSTR_URL_ERROR_INVALID,
|
||||
NOSTR_URL_ERROR_PRIVATE,
|
||||
NOSTR_URL_ERROR_CHECKSUM,
|
||||
NOSTR_URL_ERROR_TLV
|
||||
} nostr_url_error_t;
|
||||
|
||||
static GQuark nostr_url_error_quark(void) {
|
||||
return g_quark_from_static_string("nostr-url-error");
|
||||
}
|
||||
|
||||
static const char bech32_charset[] =
|
||||
"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
|
||||
static int charset_value(char c) {
|
||||
const char *p = strchr(bech32_charset, c);
|
||||
return p ? (int)(p - bech32_charset) : -1;
|
||||
}
|
||||
|
||||
static guint32 polymod_step(guint32 pre) {
|
||||
guint8 top = pre >> 25;
|
||||
guint32 chk = (pre & 0x1ffffffU) << 5;
|
||||
static const guint32 gen[5] = {
|
||||
0x3b6a57b2U, 0x26508e6dU, 0x1ea119faU, 0x3d4233ddU, 0x2a1462b3U
|
||||
};
|
||||
for (int i = 0; i < 5; i++) if ((top >> i) & 1U) chk ^= gen[i];
|
||||
return chk;
|
||||
}
|
||||
|
||||
static gboolean convert_bits(const guint8 *in, gsize in_len, int from_bits,
|
||||
int to_bits, gboolean pad, GByteArray *out) {
|
||||
guint32 acc = 0;
|
||||
int bits = 0;
|
||||
guint32 maxv = ((guint32)1 << to_bits) - 1U;
|
||||
guint32 max_acc = ((guint32)1 << (from_bits + to_bits - 1)) - 1U;
|
||||
for (gsize i = 0; i < in_len; i++) {
|
||||
if ((in[i] >> from_bits) != 0) return FALSE;
|
||||
acc = ((acc << from_bits) | in[i]) & max_acc;
|
||||
bits += from_bits;
|
||||
while (bits >= to_bits) {
|
||||
bits -= to_bits;
|
||||
guint8 value = (acc >> bits) & maxv;
|
||||
g_byte_array_append(out, &value, 1);
|
||||
}
|
||||
}
|
||||
if (pad) {
|
||||
if (bits) {
|
||||
guint8 value = (acc << (to_bits - bits)) & maxv;
|
||||
g_byte_array_append(out, &value, 1);
|
||||
}
|
||||
} else if (bits >= from_bits || ((acc << (to_bits - bits)) & maxv)) {
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean decode_bech32(const char *input, char **hrp_out,
|
||||
GByteArray **bytes_out, GError **error) {
|
||||
gsize len = input ? strlen(input) : 0;
|
||||
if (len < 8 || len > 8192) {
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
|
||||
"Invalid Bech32 length");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean lower = FALSE, upper = FALSE;
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
if ((guchar)input[i] < 33 || (guchar)input[i] > 126) goto invalid;
|
||||
if (g_ascii_islower(input[i])) lower = TRUE;
|
||||
if (g_ascii_isupper(input[i])) upper = TRUE;
|
||||
}
|
||||
if (lower && upper) goto invalid;
|
||||
|
||||
const char *sep = strrchr(input, '1');
|
||||
if (!sep || sep == input || (gsize)(input + len - sep) < 7) goto invalid;
|
||||
gsize hrp_len = sep - input;
|
||||
char *hrp = g_ascii_strdown(input, hrp_len);
|
||||
|
||||
guint32 chk = 1;
|
||||
for (gsize i = 0; i < hrp_len; i++) chk = polymod_step(chk) ^ ((guint8)hrp[i] >> 5);
|
||||
chk = polymod_step(chk);
|
||||
for (gsize i = 0; i < hrp_len; i++) chk = polymod_step(chk) ^ ((guint8)hrp[i] & 31);
|
||||
|
||||
gsize data5_len = len - hrp_len - 1;
|
||||
guint8 *data5 = g_malloc(data5_len);
|
||||
for (gsize i = 0; i < data5_len; i++) {
|
||||
int value = charset_value(g_ascii_tolower(sep[1 + i]));
|
||||
if (value < 0) {
|
||||
g_free(data5); g_free(hrp); goto invalid;
|
||||
}
|
||||
data5[i] = (guint8)value;
|
||||
chk = polymod_step(chk) ^ data5[i];
|
||||
}
|
||||
if (chk != 1U) {
|
||||
g_free(data5); g_free(hrp);
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_CHECKSUM,
|
||||
"Invalid Bech32 checksum");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
GByteArray *bytes = g_byte_array_sized_new(data5_len * 5 / 8);
|
||||
gboolean ok = convert_bits(data5, data5_len - 6, 5, 8, FALSE, bytes);
|
||||
g_free(data5);
|
||||
if (!ok || bytes->len > MAX_DECODED_BYTES) {
|
||||
g_byte_array_unref(bytes); g_free(hrp); goto invalid;
|
||||
}
|
||||
*hrp_out = hrp;
|
||||
*bytes_out = bytes;
|
||||
return TRUE;
|
||||
|
||||
invalid:
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
|
||||
"Invalid Bech32 encoding");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
const char *nostr_url_entity(const char *input) {
|
||||
if (!input) return NULL;
|
||||
if (g_ascii_strncasecmp(input, "nostr://", 8) == 0) return input + 8;
|
||||
if (g_ascii_strncasecmp(input, "nostr:", 6) == 0) return input + 6;
|
||||
return input;
|
||||
}
|
||||
|
||||
nostr_entity_type_t nostr_url_detect(const char *input) {
|
||||
const char *entity = nostr_url_entity(input);
|
||||
if (!entity || !*entity) return NOSTR_ENTITY_NONE;
|
||||
if (g_ascii_strncasecmp(entity, "nprofile1", 9) == 0) return NOSTR_ENTITY_NPROFILE;
|
||||
if (g_ascii_strncasecmp(entity, "nevent1", 7) == 0) return NOSTR_ENTITY_NEVENT;
|
||||
if (g_ascii_strncasecmp(entity, "nrelay1", 7) == 0) return NOSTR_ENTITY_NRELAY;
|
||||
if (g_ascii_strncasecmp(entity, "naddr1", 6) == 0) return NOSTR_ENTITY_NADDR;
|
||||
if (g_ascii_strncasecmp(entity, "npub1", 5) == 0) return NOSTR_ENTITY_NPUB;
|
||||
if (g_ascii_strncasecmp(entity, "nsec1", 5) == 0) return NOSTR_ENTITY_NSEC;
|
||||
if (g_ascii_strncasecmp(entity, "note1", 5) == 0) return NOSTR_ENTITY_NOTE;
|
||||
return NOSTR_ENTITY_NONE;
|
||||
}
|
||||
|
||||
static gboolean valid_utf8_text(const guint8 *data, gsize len) {
|
||||
return len > 0 && !memchr(data, '\0', len) && g_utf8_validate((const char *)data, len, NULL);
|
||||
}
|
||||
|
||||
static gboolean add_relay_hint(nostr_decoded_entity_t *out,
|
||||
const guint8 *data, gsize len) {
|
||||
if (!valid_utf8_text(data, len) || out->relay_hints->len >= MAX_RELAY_HINTS)
|
||||
return FALSE;
|
||||
char *url = g_strndup((const char *)data, len);
|
||||
if (!(g_str_has_prefix(url, "wss://") || g_str_has_prefix(url, "ws://"))) {
|
||||
g_free(url);
|
||||
return FALSE;
|
||||
}
|
||||
g_ptr_array_add(out->relay_hints, url);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean parse_tlv(const GByteArray *bytes, nostr_decoded_entity_t *out,
|
||||
GError **error) {
|
||||
gboolean have_special = FALSE;
|
||||
for (gsize pos = 0; pos < bytes->len;) {
|
||||
if (bytes->len - pos < 2) goto bad_tlv;
|
||||
guint8 type = bytes->data[pos++];
|
||||
guint8 len = bytes->data[pos++];
|
||||
if (len > bytes->len - pos) goto bad_tlv;
|
||||
const guint8 *value = bytes->data + pos;
|
||||
|
||||
switch (type) {
|
||||
case 0:
|
||||
if (have_special) goto bad_tlv;
|
||||
have_special = TRUE;
|
||||
if (out->type == NOSTR_ENTITY_NPROFILE) {
|
||||
if (len != 32) goto bad_tlv;
|
||||
memcpy(out->pubkey, value, 32); out->has_pubkey = TRUE;
|
||||
} else if (out->type == NOSTR_ENTITY_NEVENT) {
|
||||
if (len != 32) goto bad_tlv;
|
||||
memcpy(out->event_id, value, 32); out->has_event_id = TRUE;
|
||||
} else if (out->type == NOSTR_ENTITY_NADDR) {
|
||||
if (!valid_utf8_text(value, len)) goto bad_tlv;
|
||||
out->identifier = g_strndup((const char *)value, len);
|
||||
} else if (out->type == NOSTR_ENTITY_NRELAY) {
|
||||
if (!valid_utf8_text(value, len)) goto bad_tlv;
|
||||
out->relay_url = g_strndup((const char *)value, len);
|
||||
if (!(g_str_has_prefix(out->relay_url, "wss://") ||
|
||||
g_str_has_prefix(out->relay_url, "ws://"))) goto bad_tlv;
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
if (out->type != NOSTR_ENTITY_NRELAY) add_relay_hint(out, value, len);
|
||||
break;
|
||||
case 2:
|
||||
if ((out->type != NOSTR_ENTITY_NEVENT && out->type != NOSTR_ENTITY_NADDR) ||
|
||||
len != 32 || out->has_pubkey) goto bad_tlv;
|
||||
memcpy(out->pubkey, value, 32); out->has_pubkey = TRUE;
|
||||
break;
|
||||
case 3:
|
||||
if ((out->type != NOSTR_ENTITY_NEVENT && out->type != NOSTR_ENTITY_NADDR) ||
|
||||
len != 4 || out->has_kind) goto bad_tlv;
|
||||
out->kind = ((guint32)value[0] << 24) | ((guint32)value[1] << 16) |
|
||||
((guint32)value[2] << 8) | value[3];
|
||||
out->has_kind = TRUE;
|
||||
break;
|
||||
default:
|
||||
break; /* Unknown TLVs are forward-compatible. */
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
|
||||
if (!have_special ||
|
||||
(out->type == NOSTR_ENTITY_NADDR &&
|
||||
(!out->identifier || !out->has_pubkey || !out->has_kind)) ||
|
||||
(out->type == NOSTR_ENTITY_NRELAY && !out->relay_url)) goto bad_tlv;
|
||||
return TRUE;
|
||||
|
||||
bad_tlv:
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_TLV,
|
||||
"Malformed or incomplete NIP-19 TLV payload");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean nostr_url_decode(const char *input, nostr_decoded_entity_t *out,
|
||||
GError **error) {
|
||||
g_return_val_if_fail(out != NULL, FALSE);
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->relay_hints = g_ptr_array_new_with_free_func(g_free);
|
||||
|
||||
const char *entity = nostr_url_entity(input);
|
||||
if (!entity || !*entity || strchr(entity, '/') || strchr(entity, '?') || strchr(entity, '#')) {
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
|
||||
"Missing or invalid Nostr entity");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
char *hrp = NULL;
|
||||
GByteArray *bytes = NULL;
|
||||
if (!decode_bech32(entity, &hrp, &bytes, error)) goto fail;
|
||||
|
||||
if (!strcmp(hrp, "npub")) out->type = NOSTR_ENTITY_NPUB;
|
||||
else if (!strcmp(hrp, "nsec")) out->type = NOSTR_ENTITY_NSEC;
|
||||
else if (!strcmp(hrp, "note")) out->type = NOSTR_ENTITY_NOTE;
|
||||
else if (!strcmp(hrp, "nprofile")) out->type = NOSTR_ENTITY_NPROFILE;
|
||||
else if (!strcmp(hrp, "nevent")) out->type = NOSTR_ENTITY_NEVENT;
|
||||
else if (!strcmp(hrp, "naddr")) out->type = NOSTR_ENTITY_NADDR;
|
||||
else if (!strcmp(hrp, "nrelay")) out->type = NOSTR_ENTITY_NRELAY;
|
||||
else out->type = NOSTR_ENTITY_NONE;
|
||||
g_free(hrp);
|
||||
|
||||
if (out->type == NOSTR_ENTITY_NSEC) {
|
||||
g_byte_array_unref(bytes);
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_PRIVATE,
|
||||
"Private Nostr keys cannot be opened or navigated to");
|
||||
goto fail;
|
||||
}
|
||||
if (out->type == NOSTR_ENTITY_NONE) {
|
||||
g_byte_array_unref(bytes);
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
|
||||
"Unsupported NIP-19 entity type");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
gboolean ok = TRUE;
|
||||
if (out->type == NOSTR_ENTITY_NPUB || out->type == NOSTR_ENTITY_NOTE) {
|
||||
if (bytes->len != 32) ok = FALSE;
|
||||
else if (out->type == NOSTR_ENTITY_NPUB) {
|
||||
memcpy(out->pubkey, bytes->data, 32); out->has_pubkey = TRUE;
|
||||
} else {
|
||||
memcpy(out->event_id, bytes->data, 32); out->has_event_id = TRUE;
|
||||
}
|
||||
} else {
|
||||
ok = parse_tlv(bytes, out, error);
|
||||
}
|
||||
g_byte_array_unref(bytes);
|
||||
if (!ok) {
|
||||
if (error && !*error)
|
||||
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
|
||||
"Invalid NIP-19 payload length");
|
||||
goto fail;
|
||||
}
|
||||
return TRUE;
|
||||
|
||||
fail:
|
||||
nostr_decoded_entity_clear(out);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void nostr_decoded_entity_clear(nostr_decoded_entity_t *entity) {
|
||||
if (!entity) return;
|
||||
g_free(entity->identifier);
|
||||
g_free(entity->relay_url);
|
||||
if (entity->relay_hints) g_ptr_array_unref(entity->relay_hints);
|
||||
memset(entity, 0, sizeof(*entity));
|
||||
}
|
||||
|
||||
char *nostr_hex_encode32(const guint8 value[32]) {
|
||||
char *hex = g_malloc(65);
|
||||
for (int i = 0; i < 32; i++) g_snprintf(hex + i * 2, 3, "%02x", value[i]);
|
||||
hex[64] = '\0';
|
||||
return hex;
|
||||
}
|
||||
|
||||
char *nostr_url_normalize(const char *input) {
|
||||
nostr_decoded_entity_t decoded;
|
||||
if (!nostr_url_decode(input, &decoded, NULL)) return NULL;
|
||||
nostr_decoded_entity_clear(&decoded);
|
||||
return g_strdup_printf("nostr://%s", nostr_url_entity(input));
|
||||
}
|
||||
|
||||
const char *nostr_entity_type_name(nostr_entity_type_t type) {
|
||||
switch (type) {
|
||||
case NOSTR_ENTITY_NPUB: return "npub";
|
||||
case NOSTR_ENTITY_NSEC: return "nsec";
|
||||
case NOSTR_ENTITY_NOTE: return "note";
|
||||
case NOSTR_ENTITY_NEVENT: return "nevent";
|
||||
case NOSTR_ENTITY_NADDR: return "naddr";
|
||||
case NOSTR_ENTITY_NPROFILE: return "nprofile";
|
||||
case NOSTR_ENTITY_NRELAY: return "nrelay";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
60
src/nostr_url.h
Normal file
60
src/nostr_url.h
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* nostr_url.h — validated NIP-19 decoding and Nostr URI normalization
|
||||
*/
|
||||
|
||||
#ifndef NOSTR_URL_H
|
||||
#define NOSTR_URL_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
NOSTR_ENTITY_NONE,
|
||||
NOSTR_ENTITY_NPUB,
|
||||
NOSTR_ENTITY_NSEC,
|
||||
NOSTR_ENTITY_NOTE,
|
||||
NOSTR_ENTITY_NEVENT,
|
||||
NOSTR_ENTITY_NADDR,
|
||||
NOSTR_ENTITY_NPROFILE,
|
||||
NOSTR_ENTITY_NRELAY,
|
||||
} nostr_entity_type_t;
|
||||
|
||||
typedef struct {
|
||||
nostr_entity_type_t type;
|
||||
guint8 pubkey[32];
|
||||
gboolean has_pubkey;
|
||||
guint8 event_id[32];
|
||||
gboolean has_event_id;
|
||||
guint32 kind;
|
||||
gboolean has_kind;
|
||||
char *identifier;
|
||||
char *relay_url;
|
||||
GPtrArray *relay_hints; /* char*, ordered as encoded */
|
||||
} nostr_decoded_entity_t;
|
||||
|
||||
/* Prefix-only classification. Use nostr_url_decode() before trusting input. */
|
||||
nostr_entity_type_t nostr_url_detect(const char *input);
|
||||
|
||||
/* Decode and validate Bech32/Bech32 checksum, payload conversion, and TLVs.
|
||||
* nsec is recognized but deliberately rejected. Returns TRUE on success. */
|
||||
gboolean nostr_url_decode(const char *input, nostr_decoded_entity_t *out,
|
||||
GError **error);
|
||||
|
||||
void nostr_decoded_entity_clear(nostr_decoded_entity_t *entity);
|
||||
|
||||
/* Newly allocated lowercase hex representation of 32 bytes. */
|
||||
char *nostr_hex_encode32(const guint8 value[32]);
|
||||
|
||||
char *nostr_url_normalize(const char *input);
|
||||
const char *nostr_url_entity(const char *input);
|
||||
const char *nostr_entity_type_name(nostr_entity_type_t type);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NOSTR_URL_H */
|
||||
101
src/perf_probe.c
Normal file
101
src/perf_probe.c
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* perf_probe.c — per-tab performance probe injection
|
||||
*
|
||||
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
|
||||
* and is embedded into the binary by embed_web_files.sh. This module
|
||||
* wraps it with a per-tab preamble that injects a
|
||||
* <meta name="sb-tab-index" content="N"> tag into the document before
|
||||
* the probe runs, so the probe can identify which tab it is reporting
|
||||
* for.
|
||||
*
|
||||
* The preamble also short-circuits on sovereign:// pages (the probe
|
||||
* would be self-referential and noisy on internal pages). We can't
|
||||
* filter by URL at injection time because WebKitUserScript is added
|
||||
* once per webview and applies to all future loads, so the URL check
|
||||
* happens at runtime inside the preamble.
|
||||
*/
|
||||
|
||||
#include "perf_probe.h"
|
||||
#include "embedded_web_content.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* The preamble runs at document_start, before perf-probe.js. It:
|
||||
* 1. Checks the current URL — if it's a sovereign:// internal page,
|
||||
* sets a flag so the probe body no-ops.
|
||||
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
|
||||
* probe (which runs at document_start as well, but after this
|
||||
* preamble in script-order) can read its tab index.
|
||||
*
|
||||
* The tab index is baked in at injection time as a literal. */
|
||||
static char *build_preamble(int tab_index) {
|
||||
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
|
||||
char *p = g_malloc(512);
|
||||
if (p == NULL) return NULL;
|
||||
snprintf(p, 512,
|
||||
"(function(){\n"
|
||||
" 'use strict';\n"
|
||||
" try {\n"
|
||||
" var u = location.href || '';\n"
|
||||
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
|
||||
" window.__sbPerfProbeSkip = true;\n"
|
||||
" } else {\n"
|
||||
" window.__sbPerfProbeSkip = false;\n"
|
||||
" window.__sbPerfTabIndex = %d;\n"
|
||||
" }\n"
|
||||
" } catch(e) {}\n"
|
||||
"})();\n",
|
||||
tab_index);
|
||||
return p;
|
||||
}
|
||||
|
||||
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
|
||||
WebKitUserContentManager *manager =
|
||||
webkit_web_view_get_user_content_manager(webview);
|
||||
if (manager == NULL) {
|
||||
g_printerr("[perf-probe] No user content manager — probe not injected\n");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
|
||||
char *preamble = build_preamble(tab_index);
|
||||
if (preamble == NULL) return;
|
||||
|
||||
WebKitUserScript *pre_script = webkit_user_script_new(
|
||||
preamble,
|
||||
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL, /* allow list (NULL = all) */
|
||||
NULL /* block list */
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, pre_script);
|
||||
webkit_user_script_unref(pre_script);
|
||||
g_free(preamble);
|
||||
|
||||
/* The probe body itself, from the embedded www/js/perf-probe.js. */
|
||||
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
|
||||
if (f == NULL) {
|
||||
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
|
||||
return;
|
||||
}
|
||||
/* webkit_user_script_new needs a NUL-terminated string. The embedded
|
||||
* data is a byte array of known size; copy it into a NUL-terminated
|
||||
* buffer. */
|
||||
char *probe_js = g_malloc(f->size + 1);
|
||||
if (probe_js == NULL) return;
|
||||
memcpy(probe_js, f->data, f->size);
|
||||
probe_js[f->size] = '\0';
|
||||
|
||||
WebKitUserScript *probe_script = webkit_user_script_new(
|
||||
probe_js,
|
||||
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, probe_script);
|
||||
webkit_user_script_unref(probe_script);
|
||||
g_free(probe_js);
|
||||
|
||||
}
|
||||
36
src/perf_probe.h
Normal file
36
src/perf_probe.h
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* perf_probe.h — per-tab performance probe injection
|
||||
*
|
||||
* Injects www/js/perf-probe.js into a webview via
|
||||
* WebKitUserContentManager, prefixed with a tiny preamble that sets
|
||||
* the tab index (so the probe knows which tab it is reporting for).
|
||||
*
|
||||
* The probe is skipped on sovereign:// internal pages to avoid
|
||||
* self-noise and recursion. Call once per webview at tab creation,
|
||||
* after nostr_inject_setup().
|
||||
*/
|
||||
|
||||
#ifndef PERF_PROBE_H
|
||||
#define PERF_PROBE_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Inject the perf probe into the given webview, tagged with the given
|
||||
* tab index. The index is baked into a preamble that runs before the
|
||||
* probe script and sets window.__sbPerfTabIndex.
|
||||
*
|
||||
* Safe to call multiple times on the same webview (the probe guards
|
||||
* against double-injection).
|
||||
*/
|
||||
void perf_probe_setup(WebKitWebView *webview, int tab_index);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PERF_PROBE_H */
|
||||
558
src/process_info.c
Normal file
558
src/process_info.c
Normal file
@@ -0,0 +1,558 @@
|
||||
/*
|
||||
* process_info.c — process and per-tab performance diagnostics
|
||||
*
|
||||
* See process_info.h for the two-layer design. This file implements:
|
||||
*
|
||||
* Layer 1 — /proc enumeration for the main PID, WebKit child PIDs
|
||||
* (discovered via webkit_web_view_get_process_id() per tab
|
||||
* plus a PPID scan for network/gpu/storage processes), and
|
||||
* managed Tor/FIPS PIDs from net_services. CPU% is computed
|
||||
* from utime+stime deltas between successive calls.
|
||||
*
|
||||
* Layer 2 — storage of the most recent perf-probe.js report per tab
|
||||
* index, exposed to the UI and MCP tools.
|
||||
*
|
||||
* All /proc parsing is Linux-specific and uses only libc + glib.
|
||||
*/
|
||||
|
||||
#include "process_info.h"
|
||||
#include "tab_manager.h"
|
||||
#include "net_services.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <dirent.h>
|
||||
#include <ctype.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
|
||||
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
|
||||
} cpu_sample_t;
|
||||
|
||||
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
|
||||
|
||||
static cpu_sample_t *cpu_sample_get(guint pid) {
|
||||
if (g_cpu_samples == NULL) {
|
||||
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
|
||||
NULL, g_free);
|
||||
}
|
||||
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
|
||||
if (s == NULL) {
|
||||
s = g_new0(cpu_sample_t, 1);
|
||||
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static long g_clk_tck = 0;
|
||||
static long clk_tck(void) {
|
||||
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
|
||||
return g_clk_tck > 0 ? g_clk_tck : 100;
|
||||
}
|
||||
|
||||
/* ── /proc readers ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Read the first line of a /proc file into buf (NUL-terminated). */
|
||||
static int read_proc_file(const char *path, char *buf, size_t buflen) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
size_t n = fread(buf, 1, buflen - 1, f);
|
||||
buf[n] = '\0';
|
||||
fclose(f);
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
|
||||
* (2) comm (3) state (4) ppid (14) utime (15) stime
|
||||
* (22) starttime (in clock ticks since boot)
|
||||
* comm is wrapped in parens and may contain spaces, so we parse by
|
||||
* finding the last ')' and tokenizing after it. */
|
||||
static int parse_stat(guint pid, char *state_out, size_t state_sz,
|
||||
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
|
||||
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
|
||||
char buf[4096];
|
||||
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
|
||||
|
||||
/* comm: between first '(' and last ')'. */
|
||||
char *lp = strchr(buf, '(');
|
||||
char *rp = strrchr(buf, ')');
|
||||
if (lp == NULL || rp == NULL || rp <= lp) return -1;
|
||||
size_t comm_len = (size_t)(rp - lp - 1);
|
||||
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
|
||||
memcpy(comm_out, lp + 1, comm_len);
|
||||
comm_out[comm_len] = '\0';
|
||||
|
||||
/* The rest after ") " is space-separated fields starting at field 3. */
|
||||
char *rest = rp + 2;
|
||||
/* rest = "state ppid ... " — tokenize. */
|
||||
char *save = NULL;
|
||||
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
|
||||
if (tok == NULL) return -1;
|
||||
if (state_out) {
|
||||
size_t sl = strlen(tok);
|
||||
if (sl >= state_sz) sl = state_sz - 1;
|
||||
memcpy(state_out, tok, sl);
|
||||
state_out[sl] = '\0';
|
||||
}
|
||||
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
|
||||
if (tok == NULL) return -1;
|
||||
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
|
||||
|
||||
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
|
||||
* majflt, cmajflt. Skip them. */
|
||||
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
|
||||
if (tok == NULL) return -1;
|
||||
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
|
||||
if (tok == NULL) return -1;
|
||||
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
|
||||
|
||||
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
|
||||
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
|
||||
if (tok == NULL) return -1;
|
||||
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
|
||||
* the integer value in KB, or -1 if not found. */
|
||||
static long status_field_kb(guint pid, const char *field) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/status", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long val = -1;
|
||||
size_t flen = strlen(field);
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, field, flen) == 0) {
|
||||
/* e.g. "VmRSS: 12345 kB" */
|
||||
char *p = line + flen;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
val = strtol(p, NULL, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return val;
|
||||
}
|
||||
|
||||
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
|
||||
* Returns 0 on success. */
|
||||
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long pss = -1, priv_clean = 0, priv_dirty = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "Pss:", 4) == 0) {
|
||||
pss = strtol(line + 4, NULL, 10);
|
||||
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
|
||||
priv_clean = strtol(line + 14, NULL, 10);
|
||||
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
|
||||
priv_dirty = strtol(line + 14, NULL, 10);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (pss_kb) *pss_kb = pss;
|
||||
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
|
||||
return (pss < 0) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Read /proc/<pid>/io fields. Returns 0 on success. */
|
||||
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/io", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long rb = 0, wb = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "read_bytes:", 11) == 0) {
|
||||
rb = strtoll(line + 11, NULL, 10);
|
||||
} else if (strncmp(line, "write_bytes:", 12) == 0) {
|
||||
wb = strtoll(line + 12, NULL, 10);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (read_bytes) *read_bytes = rb;
|
||||
if (write_bytes) *write_bytes = wb;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
|
||||
static void read_cmdline(guint pid, char *buf, size_t buflen) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) { buf[0] = '\0'; return; }
|
||||
size_t n = fread(buf, 1, buflen - 1, f);
|
||||
fclose(f);
|
||||
if (n == 0) { buf[0] = '\0'; return; }
|
||||
buf[n] = '\0';
|
||||
/* Replace NUL separators with spaces. */
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (buf[i] == '\0') buf[i] = ' ';
|
||||
}
|
||||
/* Trim trailing space. */
|
||||
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
|
||||
}
|
||||
|
||||
/* Count entries in a /proc directory (used for threads and fds). */
|
||||
static int count_dir_entries(const char *path) {
|
||||
DIR *d = opendir(path);
|
||||
if (d == NULL) return -1;
|
||||
int count = 0;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
if (e->d_name[0] == '.') continue;
|
||||
count++;
|
||||
}
|
||||
closedir(d);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Read /proc/stat's btime (boot time in seconds since epoch). */
|
||||
static time_t g_btime = 0;
|
||||
static time_t boot_time(void) {
|
||||
if (g_btime != 0) return g_btime;
|
||||
FILE *f = fopen("/proc/stat", "r");
|
||||
if (f == NULL) return 0;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "btime", 5) == 0) {
|
||||
g_btime = (time_t)strtoll(line + 5, NULL, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return g_btime;
|
||||
}
|
||||
|
||||
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
cJSON *probe; /* most recent probe report (owned) */
|
||||
time_t ts; /* when recorded */
|
||||
} probe_slot_t;
|
||||
|
||||
/* Sparse array indexed by tab index. Grows as needed. */
|
||||
static probe_slot_t *g_probes = NULL;
|
||||
static int g_probe_cap = 0;
|
||||
|
||||
static void probe_ensure(int idx) {
|
||||
if (idx < 0) return;
|
||||
if (idx >= g_probe_cap) {
|
||||
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
|
||||
while (new_cap <= idx) new_cap *= 2;
|
||||
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
|
||||
if (arr == NULL) return;
|
||||
for (int i = g_probe_cap; i < new_cap; i++) {
|
||||
arr[i].probe = NULL;
|
||||
arr[i].ts = 0;
|
||||
}
|
||||
g_probes = arr;
|
||||
g_probe_cap = new_cap;
|
||||
}
|
||||
}
|
||||
|
||||
void process_info_record_probe(int tab_index, cJSON *probe) {
|
||||
if (tab_index < 0 || probe == NULL) {
|
||||
if (probe) cJSON_Delete(probe);
|
||||
return;
|
||||
}
|
||||
probe_ensure(tab_index);
|
||||
if (tab_index >= g_probe_cap) {
|
||||
cJSON_Delete(probe);
|
||||
return;
|
||||
}
|
||||
if (g_probes[tab_index].probe) {
|
||||
cJSON_Delete(g_probes[tab_index].probe);
|
||||
}
|
||||
g_probes[tab_index].probe = probe; /* take ownership */
|
||||
g_probes[tab_index].ts = time(NULL);
|
||||
}
|
||||
|
||||
/* Return a *reference* (not a copy) to the stored probe for a tab, or
|
||||
* NULL. Caller must NOT free. */
|
||||
static cJSON *probe_get(int tab_index) {
|
||||
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
|
||||
return g_probes[tab_index].probe;
|
||||
}
|
||||
|
||||
/* Deep-copy a probe (so the caller can free independently). */
|
||||
static cJSON *probe_copy(int tab_index) {
|
||||
cJSON *p = probe_get(tab_index);
|
||||
if (p == NULL) return NULL;
|
||||
char *s = cJSON_PrintUnformatted(p);
|
||||
cJSON *copy = cJSON_Parse(s);
|
||||
free(s);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* ── Process enumeration ────────────────────────────────────────────── */
|
||||
|
||||
/* Ownership tag for a discovered PID. */
|
||||
typedef enum {
|
||||
OWN_MAIN,
|
||||
OWN_WEBKIT_RENDERER,
|
||||
OWN_WEBKIT_NETWORK,
|
||||
OWN_WEBKIT_GPU,
|
||||
OWN_WEBKIT_STORAGE,
|
||||
OWN_TOR,
|
||||
OWN_FIPS,
|
||||
OWN_OTHER
|
||||
} proc_own_t;
|
||||
|
||||
/* Build a cJSON object for one process. `own` and `service_state`
|
||||
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
|
||||
* array (may be NULL) for renderers. */
|
||||
static cJSON *build_proc_obj(guint pid, proc_own_t own,
|
||||
const char *service_state,
|
||||
cJSON *hosted_tabs) {
|
||||
char comm[256] = "";
|
||||
char state[8] = "?";
|
||||
guint ppid = 0;
|
||||
guint64 utime = 0, stime = 0, starttime = 0;
|
||||
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
|
||||
&starttime, comm, sizeof(comm)) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
|
||||
* resolution) instead of time(NULL) (1-second resolution) so the
|
||||
* value updates on every poll even when the poll interval is ~1s. */
|
||||
cpu_sample_t *s = cpu_sample_get(pid);
|
||||
guint64 cur_jiffies = utime + stime;
|
||||
gint64 now_us = g_get_monotonic_time();
|
||||
double cpu_pct = 0.0;
|
||||
if (s->prev_wall_us > 0) {
|
||||
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
|
||||
if (dt > 0) {
|
||||
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
|
||||
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
|
||||
if (cpu_pct < 0) cpu_pct = 0;
|
||||
}
|
||||
}
|
||||
s->prev_jiffies = cur_jiffies;
|
||||
s->prev_wall_us = now_us;
|
||||
|
||||
long rss_kb = status_field_kb(pid, "VmRSS:");
|
||||
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
|
||||
long vmswap_kb = status_field_kb(pid, "VmSwap:");
|
||||
long pss_kb = -1, uss_kb = -1;
|
||||
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
|
||||
long io_read = 0, io_write = 0;
|
||||
parse_io(pid, &io_read, &io_write);
|
||||
|
||||
char task_path[64];
|
||||
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
|
||||
int threads = count_dir_entries(task_path);
|
||||
if (threads < 0) threads = 0;
|
||||
|
||||
char fd_path[64];
|
||||
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
|
||||
int fds = count_dir_entries(fd_path);
|
||||
if (fds < 0) fds = 0;
|
||||
|
||||
char cmdline[1024];
|
||||
read_cmdline(pid, cmdline, sizeof(cmdline));
|
||||
|
||||
/* Uptime: starttime is in ticks since boot; btime is boot seconds
|
||||
* since epoch. process_start = btime + starttime/clk. */
|
||||
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
|
||||
time_t uptime = time(NULL) - start_sec;
|
||||
if (uptime < 0) uptime = 0;
|
||||
|
||||
const char *own_str = "other";
|
||||
switch (own) {
|
||||
case OWN_MAIN: own_str = "main"; break;
|
||||
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
|
||||
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
|
||||
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
|
||||
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
|
||||
case OWN_TOR: own_str = "tor"; break;
|
||||
case OWN_FIPS: own_str = "fips"; break;
|
||||
default: own_str = "other"; break;
|
||||
}
|
||||
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "pid", (double)pid);
|
||||
cJSON_AddStringToObject(o, "name", comm);
|
||||
cJSON_AddStringToObject(o, "cmdline", cmdline);
|
||||
cJSON_AddStringToObject(o, "state", state);
|
||||
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
|
||||
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
|
||||
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
|
||||
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
|
||||
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
|
||||
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
|
||||
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
|
||||
cJSON_AddNumberToObject(o, "threads", threads);
|
||||
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
|
||||
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
|
||||
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
|
||||
cJSON_AddNumberToObject(o, "fd_count", fds);
|
||||
cJSON_AddStringToObject(o, "ownership", own_str);
|
||||
if (service_state) {
|
||||
cJSON_AddStringToObject(o, "service_state", service_state);
|
||||
}
|
||||
if (hosted_tabs) {
|
||||
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
|
||||
} else {
|
||||
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/* Map a net_service_type_t to a service_state string via net_services. */
|
||||
static const char *service_state_for(net_service_type_t t) {
|
||||
const net_service_t *s = net_service_get_status(t);
|
||||
if (s == NULL) return NULL;
|
||||
switch (s->state) {
|
||||
case SERVICE_DISABLED: return "disabled";
|
||||
case SERVICE_DISCOVERING: return "discovering";
|
||||
case SERVICE_ATTACHING: return "attaching";
|
||||
case SERVICE_STARTING: return "starting";
|
||||
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
|
||||
case SERVICE_READY: return "ready";
|
||||
case SERVICE_STOPPING: return "stopping";
|
||||
case SERVICE_EXITED: return "exited";
|
||||
case SERVICE_FAILED: return "failed";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* Scan /proc for child processes of main_pid whose comm starts with
|
||||
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
|
||||
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
|
||||
* (WebKitStorageProcess) and adds it to the `out` array with the
|
||||
* appropriate ownership tag.
|
||||
*
|
||||
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
|
||||
* cannot map individual tabs to specific renderer PIDs here. Per-tab
|
||||
* CPU attribution is handled by the Layer 2 probe instead. Renderer
|
||||
* rows therefore show an empty hosted_tabs array. */
|
||||
static void scan_webkit_procs(guint main_pid, cJSON *out) {
|
||||
DIR *d = opendir("/proc");
|
||||
if (d == NULL) return;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
if (!isdigit((unsigned char)e->d_name[0])) continue;
|
||||
guint pid = (guint)strtoul(e->d_name, NULL, 10);
|
||||
if (pid == 0 || pid == main_pid) continue;
|
||||
char comm[256] = "";
|
||||
char state[8] = "?";
|
||||
guint ppid = 0;
|
||||
guint64 utime = 0, stime = 0, starttime = 0;
|
||||
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
|
||||
&starttime, comm, sizeof(comm)) != 0) continue;
|
||||
if (ppid != main_pid) continue;
|
||||
if (strncmp(comm, "WebKit", 6) != 0) continue;
|
||||
proc_own_t own = OWN_OTHER;
|
||||
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
|
||||
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
|
||||
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
|
||||
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
|
||||
else continue; /* unknown WebKit aux — skip */
|
||||
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
|
||||
if (obj) cJSON_AddItemToArray(out, obj);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
cJSON *process_info_get_processes_json(void) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
guint main_pid = (guint)getpid();
|
||||
|
||||
/* Main process. */
|
||||
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
|
||||
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
|
||||
|
||||
/* WebKit child processes (renderers, network, gpu, storage).
|
||||
* Discovered via /proc PPID scan — WebKitGTK 4.1 doesn't expose
|
||||
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
|
||||
scan_webkit_procs(main_pid, arr);
|
||||
|
||||
/* Tor + FIPS managed subprocesses. */
|
||||
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
|
||||
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
|
||||
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
|
||||
service_state_for(NET_SERVICE_TOR), NULL);
|
||||
if (obj) cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
|
||||
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
|
||||
service_state_for(NET_SERVICE_FIPS), NULL);
|
||||
if (obj) cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
|
||||
|
||||
cJSON *process_info_get_tabs_json(void) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
int n = tab_manager_count();
|
||||
for (int i = 0; i < n; i++) {
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
if (tab == NULL) continue;
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "index", i);
|
||||
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
|
||||
cJSON_AddStringToObject(o, "url",
|
||||
tab->current_url[0] ? tab->current_url : "");
|
||||
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
|
||||
cJSON_AddBoolToObject(o, "is_internal", is_internal);
|
||||
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
|
||||
* report 0 here. The Processes tab lists renderer PIDs from
|
||||
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
|
||||
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
|
||||
cJSON *probe = probe_copy(i);
|
||||
if (probe) {
|
||||
cJSON_AddItemToObject(o, "probe", probe);
|
||||
} else {
|
||||
cJSON_AddNullToObject(o, "probe");
|
||||
}
|
||||
cJSON_AddItemToArray(arr, o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index) {
|
||||
tab_info_t *tab = tab_manager_get(tab_index);
|
||||
if (tab == NULL) return NULL;
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(root, "index", tab_index);
|
||||
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
|
||||
cJSON_AddStringToObject(root, "url",
|
||||
tab->current_url[0] ? tab->current_url : "");
|
||||
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
|
||||
cJSON_AddBoolToObject(root, "is_internal", is_internal);
|
||||
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
|
||||
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
|
||||
cJSON *probe = probe_copy(tab_index);
|
||||
if (probe) {
|
||||
cJSON_AddItemToObject(root, "probe", probe);
|
||||
} else {
|
||||
cJSON_AddNullToObject(root, "probe");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
82
src/process_info.h
Normal file
82
src/process_info.h
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* process_info.h — process and per-tab performance diagnostics
|
||||
*
|
||||
* Provides the data backing for the sovereign://processes internal page.
|
||||
* Two layers:
|
||||
*
|
||||
* Layer 1 — process view: enumerates the main browser PID, the WebKit
|
||||
* child processes (renderer / network / gpu / storage) and
|
||||
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
|
||||
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
|
||||
* service state. CPU% is computed from utime+stime deltas
|
||||
* between successive calls (top-style).
|
||||
*
|
||||
* Layer 2 — per-tab probe: a JS user script (www/js/perf-probe.js)
|
||||
* injected into every non-sovereign:// page reports a
|
||||
* 1-second batch of long-task, timer, network, heap, and
|
||||
* FPS samples via sovereign://processes/probe-report. The
|
||||
* latest report per tab index is stored here and exposed to
|
||||
* the UI and to MCP tools.
|
||||
*
|
||||
* All /proc parsing is Linux-specific and dependency-free.
|
||||
*/
|
||||
|
||||
#ifndef PROCESS_INFO_H
|
||||
#define PROCESS_INFO_H
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Build the Layer 1 process list as a cJSON array (caller frees).
|
||||
* Shape:
|
||||
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
|
||||
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
|
||||
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
|
||||
* "threads":12, "uptime_sec":..., "io_read_kb":...,
|
||||
* "io_write_kb":..., "fd_count":..., "ownership":"main",
|
||||
* "service_state":"ready" (Tor/FIPS only),
|
||||
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
|
||||
* (renderers only) }]
|
||||
*
|
||||
* The first call returns cpu_percent 0 for every process (no baseline);
|
||||
* subsequent calls return accurate deltas. Caller should poll ~1s.
|
||||
*/
|
||||
cJSON *process_info_get_processes_json(void);
|
||||
|
||||
/*
|
||||
* Build the Layer 2 tab list as a cJSON array (caller frees).
|
||||
* Combines tab_manager state (index, title, url, WebProcess pid) with
|
||||
* the most recent probe report for each tab (if any).
|
||||
* Shape:
|
||||
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
|
||||
* "is_internal":true,
|
||||
* "probe": { ...latest probe report, or null... } }]
|
||||
*/
|
||||
cJSON *process_info_get_tabs_json(void);
|
||||
|
||||
/*
|
||||
* Build a drill-down JSON object for a single tab (caller frees).
|
||||
* Returns the full latest probe report (including the long-task
|
||||
* timeline and top sources) plus the tab's identity fields.
|
||||
* Returns NULL if the tab index is out of range.
|
||||
*/
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index);
|
||||
|
||||
/*
|
||||
* Record a probe report received from a page's perf-probe.js.
|
||||
* Takes ownership of `probe` (frees it). `tab_index` is the
|
||||
* sovereign://processes/probe-report?tab_index=N query value.
|
||||
* Stores a copy of the report fields; the timeline and top_sources
|
||||
* arrays are preserved for the drill-down view.
|
||||
*/
|
||||
void process_info_record_probe(int tab_index, cJSON *probe);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PROCESS_INFO_H */
|
||||
279
src/profile.c
Normal file
279
src/profile.c
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* profile.c — per-user profile directory management for sovereign_browser
|
||||
*
|
||||
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
|
||||
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/.
|
||||
*/
|
||||
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Internal helpers ──────────────────────────────────────────────── */
|
||||
|
||||
/* Get ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
|
||||
static int sb_home_dir(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Max path length we use for home-derived paths. The home directory
|
||||
* can be long, and we append "/.sovereign_browser/profiles/<64-hex>/".
|
||||
* 600 bytes is generous enough for any realistic HOME path. */
|
||||
#define SB_HOME_BUF_SZ 600
|
||||
|
||||
/* mkdir -p style: create a directory and all parent directories. */
|
||||
static int mkdir_p(const char *path, mode_t mode) {
|
||||
char tmp[512];
|
||||
int n = snprintf(tmp, sizeof(tmp), "%s", path);
|
||||
if (n < 0 || (size_t)n >= sizeof(tmp)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Remove trailing slash if present. */
|
||||
size_t len = strlen(tmp);
|
||||
if (len > 0 && tmp[len - 1] == '/') {
|
||||
tmp[len - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Walk the path and create each component. */
|
||||
for (char *p = tmp + 1; *p; p++) {
|
||||
if (*p == '/') {
|
||||
*p = '\0';
|
||||
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char home[600];
|
||||
if (sb_home_dir(home, sizeof(home)) != 0) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%s/profiles/%s/", home, pubkey_hex);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int profile_ensure_dir(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return mkdir_p(dir, 0700);
|
||||
}
|
||||
|
||||
/* ── WebKit data directory ─────────────────────────────────────────── *
|
||||
* Each user's WebKitWebsiteDataManager (cookies, cache, localStorage,
|
||||
* IndexedDB, service workers, favicons) is rooted at
|
||||
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ so web data is isolated
|
||||
* per identity and persists across restarts. See plans/webkit-data-isolation.md.
|
||||
*/
|
||||
|
||||
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%swebkit/", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int profile_ensure_webkit_dir(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_webkit_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return mkdir_p(dir, 0700);
|
||||
}
|
||||
|
||||
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%sbrowser.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%sidentity.json", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void profile_get_global_identity_path(char *out, size_t out_sz) {
|
||||
if (out == NULL || out_sz == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char home[SB_HOME_BUF_SZ];
|
||||
if (sb_home_dir(home, sizeof(home)) != 0) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%s/identity.json", home);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Last-used pubkey persistence (global identity.json) ───────────── */
|
||||
|
||||
int profile_save_last_pubkey(const char *pubkey_hex) {
|
||||
char path[SB_HOME_BUF_SZ];
|
||||
profile_get_global_identity_path(path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (pubkey_hex && pubkey_hex[0]) {
|
||||
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
|
||||
} else {
|
||||
cJSON_AddNullToObject(root, "pubkey_hex");
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
if (json == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
fputs(json, f);
|
||||
fputc('\n', f);
|
||||
fclose(f);
|
||||
free(json);
|
||||
|
||||
g_print("[profile] Saved last-used pubkey: %s\n",
|
||||
(pubkey_hex && pubkey_hex[0]) ? pubkey_hex : "(cleared)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int profile_load_last_pubkey(char *out, size_t out_sz) {
|
||||
if (out == NULL || out_sz < 65) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char path[SB_HOME_BUF_SZ];
|
||||
profile_get_global_identity_path(path, sizeof(path));
|
||||
if (path[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Read the file (small JSON, a few hundred bytes at most). */
|
||||
char buf[1024];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
|
||||
const char *hex = cJSON_IsString(pk) ? pk->valuestring : NULL;
|
||||
|
||||
int rc = -1;
|
||||
if (hex && strlen(hex) == 64) {
|
||||
snprintf(out, out_sz, "%s", hex);
|
||||
rc = 0;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return rc;
|
||||
}
|
||||
92
src/profile.h
Normal file
92
src/profile.h
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* profile.h — per-user profile directory management for sovereign_browser
|
||||
*
|
||||
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
|
||||
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/
|
||||
* containing a per-user browser.db and identity.json.
|
||||
*
|
||||
* Global settings (agent server, theme, shortcuts, inspector geometry)
|
||||
* live in ~/.sovereign_browser/global.db and are shared across all users.
|
||||
*/
|
||||
|
||||
#ifndef PROFILE_H
|
||||
#define PROFILE_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Get the profile directory path for a given hex pubkey.
|
||||
* pubkey_hex — 64-char hex pubkey (no leading "0x")
|
||||
* out — output buffer
|
||||
* out_sz — size of output buffer
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/" to out.
|
||||
*/
|
||||
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Create the profile directory for the given hex pubkey if it doesn't
|
||||
* already exist (mkdir -p style — creates parent dirs as needed).
|
||||
* pubkey_hex — 64-char hex pubkey
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_ensure_dir(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Get the per-user browser.db path for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/browser.db" to out.
|
||||
*/
|
||||
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Get the per-user identity.json path for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/identity.json" to out.
|
||||
*/
|
||||
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Get the per-user WebKit data directory for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/webkit/" to out.
|
||||
* This is where the per-user WebKitWebsiteDataManager roots its cookies,
|
||||
* cache, localStorage, IndexedDB, service workers, and favicons so web
|
||||
* data is isolated per identity. See plans/webkit-data-isolation.md.
|
||||
*/
|
||||
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Create the per-user WebKit data directory if it doesn't already exist
|
||||
* (mkdir -p style). Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_ensure_webkit_dir(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Get the global identity.json path (stores the last-used pubkey_hex so
|
||||
* the login dialog can default to the right profile).
|
||||
* Writes "~/.sovereign_browser/identity.json" to out.
|
||||
*/
|
||||
void profile_get_global_identity_path(char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Save the last-used pubkey_hex to the global identity.json.
|
||||
* pubkey_hex — 64-char hex pubkey, or NULL/empty to clear
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_save_last_pubkey(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Read the last-used pubkey_hex from the global identity.json.
|
||||
* out — output buffer (must be at least 65 bytes)
|
||||
* out_sz — size of output buffer
|
||||
* Returns 0 on success (out filled with hex pubkey), -1 if not found or
|
||||
* error (out is left unchanged).
|
||||
*/
|
||||
int profile_load_last_pubkey(char *out, size_t out_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PROFILE_H */
|
||||
@@ -13,6 +13,9 @@
|
||||
#include "relay_fetch.h"
|
||||
#include "db.h"
|
||||
#include "settings.h"
|
||||
#include "bookmarks.h"
|
||||
#include "settings_sync.h"
|
||||
#include "tab_manager.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -27,6 +30,23 @@
|
||||
/* Relay query timeout in seconds. */
|
||||
#define RELAY_TIMEOUT_SECONDS 15
|
||||
|
||||
/* Forward declaration — defined after relay_fetch_bootstrap. */
|
||||
static gboolean avatar_refresh_idle(gpointer data);
|
||||
static GMutex relay_query_mutex;
|
||||
|
||||
struct cJSON **relay_query_synchronized(
|
||||
const char **relay_urls, int relay_count, struct cJSON *filter,
|
||||
relay_query_mode_t mode, int *result_count, int timeout_seconds,
|
||||
relay_progress_callback_t callback, void *user_data,
|
||||
int nip42_enabled, const unsigned char *private_key) {
|
||||
g_mutex_lock(&relay_query_mutex);
|
||||
struct cJSON **results = synchronous_query_relays_with_progress(
|
||||
relay_urls, relay_count, filter, mode, result_count, timeout_seconds,
|
||||
callback, user_data, nip42_enabled, private_key);
|
||||
g_mutex_unlock(&relay_query_mutex);
|
||||
return results;
|
||||
}
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
@@ -41,10 +61,10 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[relay] Fetching kind 0/3/10002 for %s from %d relay(s)...\n",
|
||||
g_print("[relay] Fetching kind 0/3/10002/30003/30078/31123 for %s from %d relay(s)...\n",
|
||||
pubkey_hex, relay_count);
|
||||
|
||||
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002]} */
|
||||
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
|
||||
@@ -54,12 +74,14 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* bookmark sets */
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30078)); /* NIP-78 app data */
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
|
||||
* from all relays. No NIP-42 auth needed for public events. */
|
||||
int result_count = 0;
|
||||
cJSON **results = synchronous_query_relays_with_progress(
|
||||
cJSON **results = relay_query_synchronized(
|
||||
relay_urls, relay_count, filter,
|
||||
RELAY_QUERY_ALL_RESULTS, &result_count,
|
||||
RELAY_TIMEOUT_SECONDS,
|
||||
@@ -76,15 +98,40 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Store each event in the database. */
|
||||
/* Store each event in the database. Kind 30003 (bookmark sets) are
|
||||
* also decrypted and loaded into the in-memory bookmarks list.
|
||||
* Kind 30078 (NIP-78 app data) is stored and merged into local
|
||||
* settings if it's the shared d:user-settings event (or the legacy
|
||||
* d:sovereign_browser event, which is migrated on merge). */
|
||||
int stored = 0;
|
||||
for (int i = 0; i < result_count; i++) {
|
||||
if (results[i] == NULL) continue;
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
|
||||
long kind = 0;
|
||||
cJSON *k = cJSON_GetObjectItemCaseSensitive(results[i], "kind");
|
||||
if (cJSON_IsNumber(k)) kind = (long)k->valuedouble;
|
||||
|
||||
if (kind == 30003) {
|
||||
/* bookmarks_store_and_load_event stores in SQLite + decrypts. */
|
||||
if (bookmarks_store_and_load_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
} else if (kind == 30078) {
|
||||
/* Store in SQLite first, then try to merge into local settings.
|
||||
* settings_sync_merge_from_nostr checks the d-tag and only
|
||||
* merges if it's the shared "user-settings" event (or the
|
||||
* legacy "sovereign_browser" event, which is migrated). */
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
settings_sync_merge_from_nostr(results[i]);
|
||||
} else {
|
||||
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
|
||||
result_count);
|
||||
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]);
|
||||
}
|
||||
@@ -92,9 +139,75 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
|
||||
g_print("[relay] Fetched %d event(s), stored %d in database\n",
|
||||
result_count, stored);
|
||||
|
||||
/* Refresh the user's avatar now that the kind 0 profile may be
|
||||
* available in the database. This must run on the main thread
|
||||
* because it touches GTK widgets. */
|
||||
g_idle_add(avatar_refresh_idle, g_strdup(pubkey_hex));
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* Fetch kind 31123 skill events from relays. Skills are PUBLIC events
|
||||
* authored by ANY user (not just the logged-in user), so this uses a
|
||||
* separate query without an authors filter. The events are stored in
|
||||
* the SQLite cache for agent_skills_fetch() to read.
|
||||
*
|
||||
* Returns the number of events stored, or 0 on error. */
|
||||
static int relay_fetch_skills(const char **relay_urls, int relay_count) {
|
||||
if (relay_urls == NULL || relay_count <= 0) return 0;
|
||||
|
||||
g_print("[relay] Fetching kind 31123 skills from %d relay(s)...\n",
|
||||
relay_count);
|
||||
|
||||
/* Build the filter: {"kinds": [31123], "limit": 500} — no authors
|
||||
* filter since skills are public and from any author. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(31123));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddNumberToObject(filter, "limit", 500);
|
||||
|
||||
int result_count = 0;
|
||||
cJSON **results = relay_query_synchronized(
|
||||
relay_urls, relay_count, filter,
|
||||
RELAY_QUERY_ALL_RESULTS, &result_count,
|
||||
RELAY_TIMEOUT_SECONDS,
|
||||
NULL, NULL,
|
||||
0, NULL
|
||||
);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (results == NULL || result_count <= 0) {
|
||||
g_print("[relay] No skill events found\n");
|
||||
if (results) free(results);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int stored = 0;
|
||||
for (int i = 0; i < result_count; i++) {
|
||||
if (results[i] == NULL) continue;
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
cJSON_Delete(results[i]);
|
||||
}
|
||||
free(results);
|
||||
|
||||
g_print("[relay] Fetched %d skill event(s), stored %d\n",
|
||||
result_count, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* Idle callback wrapper to refresh the avatar on the main thread. */
|
||||
static gboolean avatar_refresh_idle(gpointer data) {
|
||||
char *pubkey = (char *)data;
|
||||
tab_manager_set_avatar(pubkey);
|
||||
g_free(pubkey);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── Background thread ─────────────────────────────────────────────── */
|
||||
|
||||
gpointer relay_fetch_thread(gpointer data) {
|
||||
@@ -133,6 +246,9 @@ gpointer relay_fetch_thread(gpointer data) {
|
||||
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
|
||||
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
|
||||
|
||||
/* Fetch public kind 31123 skill events (from any author). */
|
||||
relay_fetch_skills(relay_urls, relay_count);
|
||||
|
||||
g_free(relay_buf);
|
||||
g_free(pubkey_hex);
|
||||
return NULL;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define RELAY_FETCH_H
|
||||
|
||||
#include <glib.h>
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -38,6 +39,15 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
*/
|
||||
gpointer relay_fetch_thread(gpointer data);
|
||||
|
||||
/* Thread-safe serialization wrapper around nostr_core_lib's synchronous
|
||||
* one-off relay client. The vendored client uses shared process state and
|
||||
* must not be entered concurrently by startup and URI fetch workers. */
|
||||
struct cJSON **relay_query_synchronized(
|
||||
const char **relay_urls, int relay_count, struct cJSON *filter,
|
||||
relay_query_mode_t mode, int *result_count, int timeout_seconds,
|
||||
relay_progress_callback_t callback, void *user_data,
|
||||
int nip42_enabled, const unsigned char *private_key);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
367
src/search.c
Normal file
367
src/search.c
Normal file
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* search.c — search engine integration for sovereign_browser
|
||||
*
|
||||
* Implements search engine configuration, URL building, async autocomplete
|
||||
* suggestion fetching via libsoup, and a URL-vs-query heuristic.
|
||||
*
|
||||
* The active engine is stored in settings ("search_engine" key) and
|
||||
* synced via NIP-78 (settings_sync).
|
||||
*/
|
||||
|
||||
#include "search.h"
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <libsoup/soup.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Vendored cJSON for parsing suggestion API responses. */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Built-in search engines ────────────────────────────────────────── */
|
||||
|
||||
static const search_engine_t g_engines[] = {
|
||||
{
|
||||
"duckduckgo", "DuckDuckGo",
|
||||
"https://duckduckgo.com/?q=%s",
|
||||
"https://duckduckgo.com/ac/?q=%s&type=list"
|
||||
},
|
||||
{
|
||||
"google", "Google",
|
||||
"https://www.google.com/search?q=%s",
|
||||
"https://suggestqueries.google.com/complete/search?client=firefox&q=%s"
|
||||
},
|
||||
{
|
||||
"brave", "Brave Search",
|
||||
"https://search.brave.com/search?q=%s",
|
||||
"https://search.brave.com/api/suggest?q=%s"
|
||||
},
|
||||
{
|
||||
"startpage", "Startpage",
|
||||
"https://www.startpage.com/sp/search?query=%s",
|
||||
NULL /* no public autocomplete API */
|
||||
},
|
||||
{
|
||||
"searx", "Searx",
|
||||
"https://searx.be/search?q=%s",
|
||||
NULL
|
||||
},
|
||||
{ NULL, NULL, NULL, NULL } /* sentinel */
|
||||
};
|
||||
|
||||
const search_engine_t *search_engines_get(void) {
|
||||
return g_engines;
|
||||
}
|
||||
|
||||
int search_engines_count(void) {
|
||||
int count = 0;
|
||||
while (g_engines[count].id != NULL) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
const search_engine_t *search_engine_by_id(const char *id) {
|
||||
if (id == NULL || id[0] == '\0') return NULL;
|
||||
for (int i = 0; g_engines[i].id != NULL; i++) {
|
||||
if (strcmp(g_engines[i].id, id) == 0) {
|
||||
return &g_engines[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const search_engine_t *search_engine_get_active(void) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
const search_engine_t *engine = search_engine_by_id(s->search_engine);
|
||||
if (engine == NULL) {
|
||||
/* Fall back to DuckDuckGo if the configured engine is unknown. */
|
||||
engine = &g_engines[0];
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
int search_engine_set_active(const char *id) {
|
||||
if (search_engine_by_id(id) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
snprintf(s->search_engine, sizeof(s->search_engine), "%s", id);
|
||||
settings_save();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── URL building ──────────────────────────────────────────────────── */
|
||||
|
||||
char *search_build_search_url_for(const search_engine_t *engine,
|
||||
const char *query) {
|
||||
if (engine == NULL || query == NULL) return NULL;
|
||||
|
||||
/* URL-encode the query for use in a URL query parameter. */
|
||||
char *encoded = g_uri_escape_string(query, NULL, FALSE);
|
||||
if (encoded == NULL) return NULL;
|
||||
|
||||
char *url = g_strdup_printf(engine->search_url, encoded);
|
||||
g_free(encoded);
|
||||
return url;
|
||||
}
|
||||
|
||||
char *search_build_search_url(const char *query) {
|
||||
return search_build_search_url_for(search_engine_get_active(), query);
|
||||
}
|
||||
|
||||
/* ── URL heuristic ─────────────────────────────────────────────────── */
|
||||
|
||||
gboolean search_is_url(const char *input) {
|
||||
if (input == NULL || input[0] == '\0') return FALSE;
|
||||
|
||||
/* Has a scheme (e.g. "https://", "http://", "ftp://"). */
|
||||
if (strstr(input, "://") != NULL) return TRUE;
|
||||
|
||||
/* Internal about: pages. */
|
||||
if (strncmp(input, "about:", 6) == 0) return TRUE;
|
||||
|
||||
/* sovereign:// internal pages. */
|
||||
if (strncmp(input, "sovereign://", 12) == 0) return TRUE;
|
||||
|
||||
/* If it contains spaces, it's almost certainly a search query. */
|
||||
if (strchr(input, ' ') != NULL) return FALSE;
|
||||
|
||||
/* "localhost" or "localhost:port". */
|
||||
if (strncmp(input, "localhost", 9) == 0) return TRUE;
|
||||
|
||||
/* Check for a dot — looks like a domain name.
|
||||
* But require at least one char before and after the dot. */
|
||||
const char *dot = strchr(input, '.');
|
||||
if (dot != NULL && dot > input && dot[1] != '\0') {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Check for IPv4 address (4 numbers separated by dots). */
|
||||
{
|
||||
int a, b, c, d;
|
||||
if (sscanf(input, "%d.%d.%d.%d", &a, &b, &c, &d) == 4) {
|
||||
if (a >= 0 && a <= 255 && b >= 0 && b <= 255 &&
|
||||
c >= 0 && c <= 255 && d >= 0 && d <= 255) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* ── Async suggestion fetch ────────────────────────────────────────── */
|
||||
|
||||
/* A pending suggestion request. */
|
||||
typedef struct {
|
||||
guint id; /* unique request ID */
|
||||
SoupSession *session; /* libsoup session */
|
||||
SoupMessage *msg; /* in-flight HTTP request */
|
||||
search_suggest_callback callback;
|
||||
gpointer user_data;
|
||||
GCancellable *cancellable;
|
||||
} suggest_request_t;
|
||||
|
||||
/* Global counter for request IDs. */
|
||||
static guint g_next_request_id = 1;
|
||||
|
||||
/* Active requests, keyed by ID. We keep a simple list since there's
|
||||
* typically only one active at a time (the latest keystroke). */
|
||||
static GList *g_active_requests = NULL;
|
||||
|
||||
/*
|
||||
* Parse a suggestion API response.
|
||||
*
|
||||
* DuckDuckGo returns: ["query", ["sugg1", "sugg2", ...]]
|
||||
* Google (client=firefox) returns: ["query", ["sugg1", "sugg2", ...]]
|
||||
* Brave returns: ["sugg1", "sugg2", ...] (plain array of strings)
|
||||
*
|
||||
* Returns a NULL-terminated array of newly allocated strings, or NULL.
|
||||
*/
|
||||
static char **parse_suggestions(const char *body, gsize body_len) {
|
||||
if (body == NULL || body_len == 0) return NULL;
|
||||
|
||||
cJSON *root = cJSON_ParseWithLength(body, body_len);
|
||||
if (root == NULL) return NULL;
|
||||
|
||||
char **results = g_new0(char *, SEARCH_SUGGESTION_MAX + 1);
|
||||
int count = 0;
|
||||
|
||||
if (cJSON_IsArray(root)) {
|
||||
int arr_size = cJSON_GetArraySize(root);
|
||||
|
||||
/* DuckDuckGo / Google format: first element is the query string,
|
||||
* second element is an array of suggestions. */
|
||||
if (arr_size >= 2 && cJSON_IsString(cJSON_GetArrayItem(root, 0))) {
|
||||
cJSON *suggestions = cJSON_GetArrayItem(root, 1);
|
||||
if (cJSON_IsArray(suggestions)) {
|
||||
int sugg_size = cJSON_GetArraySize(suggestions);
|
||||
for (int i = 0; i < sugg_size && count < SEARCH_SUGGESTION_MAX; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(suggestions, i);
|
||||
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
|
||||
results[count++] = g_strdup(item->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Brave format: plain array of strings. */
|
||||
for (int i = 0; i < arr_size && count < SEARCH_SUGGESTION_MAX; i++) {
|
||||
cJSON *item = cJSON_GetArrayItem(root, i);
|
||||
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
|
||||
results[count++] = g_strdup(item->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (count == 0) {
|
||||
g_free(results);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/*
|
||||
* Idle callback to deliver results on the main thread.
|
||||
*/
|
||||
typedef struct {
|
||||
search_suggest_callback callback;
|
||||
char **suggestions;
|
||||
gpointer user_data;
|
||||
} idle_delivery_t;
|
||||
|
||||
static gboolean deliver_suggestions_idle(gpointer data) {
|
||||
idle_delivery_t *delivery = (idle_delivery_t *)data;
|
||||
delivery->callback(delivery->suggestions, delivery->user_data);
|
||||
|
||||
/* Free the suggestions array after the callback has consumed them. */
|
||||
if (delivery->suggestions) {
|
||||
for (int i = 0; delivery->suggestions[i] != NULL; i++) {
|
||||
g_free(delivery->suggestions[i]);
|
||||
}
|
||||
g_free(delivery->suggestions);
|
||||
}
|
||||
g_free(delivery);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/*
|
||||
* GAsyncReadyCallback for soup_session_send_and_read_async.
|
||||
* Called when the full response body has been downloaded.
|
||||
*/
|
||||
static void on_suggest_async_ready(GObject *source_object, GAsyncResult *res,
|
||||
gpointer user_data) {
|
||||
suggest_request_t *req = (suggest_request_t *)user_data;
|
||||
SoupSession *session = SOUP_SESSION(source_object);
|
||||
|
||||
char **suggestions = NULL;
|
||||
|
||||
GBytes *bytes = soup_session_send_and_read_finish(session, res, NULL);
|
||||
if (bytes != NULL) {
|
||||
gsize size = 0;
|
||||
const gchar *data = g_bytes_get_data(bytes, &size);
|
||||
if (data && size > 0) {
|
||||
suggestions = parse_suggestions(data, size);
|
||||
}
|
||||
g_bytes_unref(bytes);
|
||||
}
|
||||
|
||||
/* Deliver on the main thread via an idle handler. We're already on
|
||||
* the main thread (libsoup-3.0 async callbacks run on the thread
|
||||
* that started the request), but the idle handler avoids re-entrancy
|
||||
* issues with the entry completion widget. */
|
||||
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
|
||||
delivery->callback = req->callback;
|
||||
delivery->suggestions = suggestions;
|
||||
delivery->user_data = req->user_data;
|
||||
g_idle_add(deliver_suggestions_idle, delivery);
|
||||
|
||||
/* Clean up. */
|
||||
g_active_requests = g_list_remove(g_active_requests, req);
|
||||
g_object_unref(req->msg);
|
||||
if (req->cancellable) g_object_unref(req->cancellable);
|
||||
g_object_unref(req->session);
|
||||
g_free(req);
|
||||
}
|
||||
|
||||
guint search_suggest_fetch_async(const char *query,
|
||||
search_suggest_callback callback,
|
||||
gpointer user_data) {
|
||||
if (query == NULL || query[0] == '\0' || callback == NULL) return 0;
|
||||
|
||||
const search_engine_t *engine = search_engine_get_active();
|
||||
if (engine->suggest_url == NULL) {
|
||||
/* Engine has no suggestion API — deliver NULL immediately. */
|
||||
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
|
||||
delivery->callback = callback;
|
||||
delivery->suggestions = NULL;
|
||||
delivery->user_data = user_data;
|
||||
g_idle_add(deliver_suggestions_idle, delivery);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build the suggestion URL. */
|
||||
char *encoded = g_uri_escape_string(query, NULL, FALSE);
|
||||
if (encoded == NULL) return 0;
|
||||
|
||||
char *url = g_strdup_printf(engine->suggest_url, encoded);
|
||||
g_free(encoded);
|
||||
if (url == NULL) return 0;
|
||||
|
||||
/* Create the request. */
|
||||
suggest_request_t *req = g_new0(suggest_request_t, 1);
|
||||
req->id = g_next_request_id++;
|
||||
req->callback = callback;
|
||||
req->user_data = user_data;
|
||||
req->cancellable = g_cancellable_new();
|
||||
|
||||
/* Create a SoupSession per request. Suggestion requests are
|
||||
* infrequent (one per keystroke with debouncing) and this avoids
|
||||
* thread-safety concerns with a shared session. */
|
||||
req->session = soup_session_new();
|
||||
g_object_set(req->session, "timeout", 5, "idle-timeout", 5, NULL);
|
||||
|
||||
req->msg = soup_message_new("GET", url);
|
||||
g_free(url);
|
||||
if (req->msg == NULL) {
|
||||
g_object_unref(req->cancellable);
|
||||
g_object_unref(req->session);
|
||||
g_free(req);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Set a User-Agent so the APIs don't block us. */
|
||||
soup_message_headers_replace(soup_message_get_request_headers(req->msg),
|
||||
"User-Agent",
|
||||
"sovereign_browser/1.0");
|
||||
|
||||
g_active_requests = g_list_prepend(g_active_requests, req);
|
||||
|
||||
/* Send the request asynchronously. on_suggest_async_ready is
|
||||
* invoked when the full response body has been downloaded. */
|
||||
soup_session_send_and_read_async(req->session, req->msg,
|
||||
G_PRIORITY_DEFAULT,
|
||||
req->cancellable,
|
||||
on_suggest_async_ready,
|
||||
req);
|
||||
|
||||
return req->id;
|
||||
}
|
||||
|
||||
void search_suggest_cancel(guint request_id) {
|
||||
if (request_id == 0) return;
|
||||
|
||||
GList *l = g_active_requests;
|
||||
while (l != NULL) {
|
||||
suggest_request_t *req = (suggest_request_t *)l->data;
|
||||
if (req->id == request_id) {
|
||||
g_cancellable_cancel(req->cancellable);
|
||||
return; /* The async callback will handle cleanup. */
|
||||
}
|
||||
l = l->next;
|
||||
}
|
||||
}
|
||||
137
src/search.h
Normal file
137
src/search.h
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* search.h — search engine integration for sovereign_browser
|
||||
*
|
||||
* Provides search engine configuration (DuckDuckGo default, with Google,
|
||||
* Brave, Startpage, and Searx as alternatives), URL building from query
|
||||
* strings, async autocomplete suggestion fetching, and a URL-vs-query
|
||||
* heuristic for the URL bar.
|
||||
*
|
||||
* The active engine name is stored in settings ("search_engine" key) and
|
||||
* synced across devices via NIP-78.
|
||||
*/
|
||||
|
||||
#ifndef SEARCH_H
|
||||
#define SEARCH_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Search engine definitions ──────────────────────────────────────── */
|
||||
|
||||
#define SEARCH_ENGINE_NAME_MAX 64
|
||||
#define SEARCH_ENGINE_URL_MAX 512
|
||||
#define SEARCH_SUGGESTION_MAX 128 /* max suggestions per request */
|
||||
#define SEARCH_QUERY_MAX 1024
|
||||
|
||||
typedef struct {
|
||||
const char *id; /* short identifier, e.g. "duckduckgo" */
|
||||
const char *name; /* display name, e.g. "DuckDuckGo" */
|
||||
const char *search_url; /* URL template with %s for the query */
|
||||
const char *suggest_url; /* autocomplete API URL template, or NULL */
|
||||
} search_engine_t;
|
||||
|
||||
/*
|
||||
* Get the list of built-in search engines (NULL-terminated array).
|
||||
* The array is static and valid for the lifetime of the program.
|
||||
*/
|
||||
const search_engine_t *search_engines_get(void);
|
||||
|
||||
/*
|
||||
* Get the number of built-in search engines.
|
||||
*/
|
||||
int search_engines_count(void);
|
||||
|
||||
/*
|
||||
* Look up a search engine by its id string.
|
||||
* Returns the engine, or NULL if not found.
|
||||
*/
|
||||
const search_engine_t *search_engine_by_id(const char *id);
|
||||
|
||||
/*
|
||||
* Get the currently active search engine (from settings).
|
||||
* Always returns a valid engine — falls back to DuckDuckGo if the
|
||||
* configured engine is unknown or unset.
|
||||
*/
|
||||
const search_engine_t *search_engine_get_active(void);
|
||||
|
||||
/*
|
||||
* Set the active search engine by id. Persists to settings.
|
||||
* Returns 0 on success, -1 if the id is unknown.
|
||||
*/
|
||||
int search_engine_set_active(const char *id);
|
||||
|
||||
/* ── URL building ──────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Build a search results URL for the given query using the active engine.
|
||||
* query — the search query string (raw, will be URL-encoded)
|
||||
* Returns a newly allocated string (caller must g_free).
|
||||
*/
|
||||
char *search_build_search_url(const char *query);
|
||||
|
||||
/*
|
||||
* Build a search results URL for the given query using a specific engine.
|
||||
* Returns a newly allocated string (caller must g_free).
|
||||
*/
|
||||
char *search_build_search_url_for(const search_engine_t *engine,
|
||||
const char *query);
|
||||
|
||||
/* ── URL heuristic ─────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Determine whether the given input string looks like a URL rather than
|
||||
* a search query.
|
||||
*
|
||||
* Returns TRUE if:
|
||||
* - It contains "://" (has a scheme)
|
||||
* - It starts with "about:" (internal pages)
|
||||
* - It has no spaces AND contains a dot (looks like a domain)
|
||||
* - It's a valid-looking IPv4 address
|
||||
* - It's "localhost" or starts with "localhost:"
|
||||
*/
|
||||
gboolean search_is_url(const char *input);
|
||||
|
||||
/* ── Async suggestion fetch ────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Callback invoked when autocomplete suggestions have been fetched.
|
||||
* suggestions — NULL-terminated array of newly allocated strings,
|
||||
* or NULL on error / no suggestions
|
||||
* user_data — the pointer passed to search_suggest_fetch_async()
|
||||
*/
|
||||
typedef void (*search_suggest_callback)(char **suggestions,
|
||||
gpointer user_data);
|
||||
|
||||
/*
|
||||
* Asynchronously fetch autocomplete suggestions for the given query
|
||||
* from the active search engine's suggestion API.
|
||||
*
|
||||
* If the active engine has no suggestion URL, the callback is called
|
||||
* immediately with NULL. The callback is always invoked on the GTK
|
||||
* main thread (via g_idle_add), so it's safe to touch GTK widgets.
|
||||
*
|
||||
* query — the partial search query
|
||||
* callback — called with the results (or NULL on error)
|
||||
* user_data — passed to the callback
|
||||
*
|
||||
* Returns a guint request ID (can be used with search_suggest_cancel()),
|
||||
* or 0 if the request could not be started.
|
||||
*/
|
||||
guint search_suggest_fetch_async(const char *query,
|
||||
search_suggest_callback callback,
|
||||
gpointer user_data);
|
||||
|
||||
/*
|
||||
* Cancel a pending suggestion request by its ID.
|
||||
* Safe to call with 0 (no-op) or an already-completed request ID.
|
||||
*/
|
||||
void search_suggest_cancel(guint request_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SEARCH_H */
|
||||
112
src/session.c
112
src/session.c
@@ -1,71 +1,45 @@
|
||||
/*
|
||||
* session.c — tab session save/restore for sovereign_browser
|
||||
*
|
||||
* Saves open tab URLs to ~/.sovereign_browser/session.txt (one per line)
|
||||
* and restores them on startup if settings.restore_session is true.
|
||||
* Saves open tab URLs (and titles) to the SQLite database (session table)
|
||||
* on window close, and restores them on startup if settings.restore_session
|
||||
* is true.
|
||||
*/
|
||||
|
||||
#include "session.h"
|
||||
#include "settings.h"
|
||||
#include "tab_manager.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SESSION_URL_MAX 2048
|
||||
|
||||
/* ── Path helper ──────────────────────────────────────────────────── */
|
||||
|
||||
static int session_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = snprintf(out, out_sz, "%s/session.txt", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Save ─────────────────────────────────────────────────────────── */
|
||||
|
||||
void session_save(void) {
|
||||
char path[512];
|
||||
if (session_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
int count = tab_manager_count();
|
||||
|
||||
/* Build arrays of URLs and titles. */
|
||||
const char **urls = g_new0(const char *, count);
|
||||
const char **titles = g_new0(const char *, count);
|
||||
|
||||
int valid = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *url = tab_manager_get_url(i);
|
||||
if (url != NULL && url[0] != '\0') {
|
||||
fprintf(f, "%s\n", url);
|
||||
urls[valid] = url;
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
titles[valid] = (tab && tab->title[0]) ? tab->title : "";
|
||||
valid++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
g_print("[session] Saved %d tab(s)\n", count);
|
||||
|
||||
db_session_save(urls, titles, valid);
|
||||
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
g_print("[session] Saved %d tab(s)\n", valid);
|
||||
}
|
||||
|
||||
/* ── Restore ──────────────────────────────────────────────────────── */
|
||||
@@ -76,32 +50,40 @@ int session_restore(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char path[512];
|
||||
if (session_path(path, sizeof(path)) != 0) {
|
||||
return 0;
|
||||
}
|
||||
char **urls = NULL;
|
||||
char **titles = NULL;
|
||||
int count = 0;
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
int rc = db_session_load(&urls, &titles, &count);
|
||||
if (rc < 0 || count == 0) {
|
||||
if (urls) {
|
||||
for (int i = 0; i < count; i++) g_free(urls[i]);
|
||||
g_free(urls);
|
||||
}
|
||||
if (titles) {
|
||||
for (int i = 0; i < count; i++) g_free(titles[i]);
|
||||
g_free(titles);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int restored = 0;
|
||||
char line[SESSION_URL_MAX];
|
||||
while (fgets(line, sizeof(line), f) != NULL) {
|
||||
/* Strip newline. */
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
if (len == 0) continue;
|
||||
|
||||
int index = tab_manager_new_tab(line);
|
||||
if (index >= 0) {
|
||||
restored++;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (urls[i] && urls[i][0]) {
|
||||
int index = tab_manager_new_tab(urls[i]);
|
||||
if (index >= 0) {
|
||||
restored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
/* Free the arrays. */
|
||||
for (int i = 0; i < count; i++) {
|
||||
g_free(urls[i]);
|
||||
g_free(titles[i]);
|
||||
}
|
||||
g_free(urls);
|
||||
g_free(titles);
|
||||
|
||||
g_print("[session] Restored %d tab(s)\n", restored);
|
||||
return restored;
|
||||
|
||||
571
src/settings.c
571
src/settings.c
@@ -1,10 +1,13 @@
|
||||
/*
|
||||
* settings.c — browser preferences for sovereign_browser
|
||||
*
|
||||
* Persists settings to ~/.sovereign_browser/settings.conf as key=value lines.
|
||||
* Persists settings to the SQLite database (key_value table). Each setting
|
||||
* is stored as a key-value pair. The database must be initialized (db_init())
|
||||
* before settings_load() is called.
|
||||
*/
|
||||
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
@@ -12,10 +15,37 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
|
||||
/* ── Global.db path helper ─────────────────────────────────────────── *
|
||||
* Returns the path to ~/.sovereign_browser/global.db (creating the
|
||||
* directory if needed). Used by settings_save_global() to write global
|
||||
* settings to global.db via db_kv_set_to_file(), even when the per-user
|
||||
* browser.db is the main open database.
|
||||
*/
|
||||
static void global_db_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
char dir[512];
|
||||
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
|
||||
if (n < 0 || (size_t)n >= sizeof(dir)) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
n = snprintf(out, out_sz, "%s/global.db", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global singleton ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -24,7 +54,7 @@ static browser_settings_t g_settings;
|
||||
/* ── Defaults ─────────────────────────────────────────────────────── */
|
||||
|
||||
static void settings_set_defaults(browser_settings_t *s) {
|
||||
s->restore_session = TRUE;
|
||||
s->restore_session = FALSE;
|
||||
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
|
||||
SETTINGS_NEW_TAB_URL_DEFAULT);
|
||||
s->tab_bar_position = GTK_POS_TOP;
|
||||
@@ -34,35 +64,68 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
s->max_tabs = SETTINGS_MAX_TABS_DEFAULT;
|
||||
s->tab_drag_reorder = TRUE;
|
||||
s->agent_server_enabled = TRUE;
|
||||
s->json_viewer_enabled = TRUE;
|
||||
s->agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins), "*");
|
||||
s->agent_login_timeout_ms = SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT;
|
||||
snprintf(s->bootstrap_relays, sizeof(s->bootstrap_relays), "%s",
|
||||
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
|
||||
}
|
||||
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
|
||||
SETTINGS_SEARCH_ENGINE_DEFAULT);
|
||||
snprintf(s->nostr_helper_apps, sizeof(s->nostr_helper_apps), "%s",
|
||||
SETTINGS_NOSTR_HELPER_APPS_DEFAULT);
|
||||
s->theme_dark = FALSE; /* default: light mode */
|
||||
s->inspector_x = -1; /* -1 = let window manager decide */
|
||||
s->inspector_y = -1;
|
||||
s->inspector_w = -1;
|
||||
s->inspector_h = -1;
|
||||
/* Agent LLM provider settings */
|
||||
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), "%s",
|
||||
SETTINGS_AGENT_LLM_BASE_URL_DEFAULT);
|
||||
s->agent_llm_api_key[0] = '\0'; /* empty by default */
|
||||
snprintf(s->agent_llm_model, sizeof(s->agent_llm_model), "%s",
|
||||
SETTINGS_AGENT_LLM_MODEL_DEFAULT);
|
||||
s->agent_llm_system_prompt[0] = '\0'; /* legacy alias for skill_template */
|
||||
s->agent_max_iterations = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
|
||||
|
||||
/* ── Path helper ──────────────────────────────────────────────────── */
|
||||
/* Sovereign Browser Skill defaults. The template defaults to the
|
||||
* built-in system prompt; the other fields describe the skill. */
|
||||
snprintf(s->agent_skill_name,
|
||||
sizeof(s->agent_skill_name), "%s",
|
||||
SETTINGS_AGENT_SKILL_NAME_DEFAULT);
|
||||
snprintf(s->agent_skill_description,
|
||||
sizeof(s->agent_skill_description), "%s",
|
||||
SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
|
||||
snprintf(s->agent_skill_template,
|
||||
sizeof(s->agent_skill_template), "%s",
|
||||
SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
|
||||
snprintf(s->agent_skill_requires_tools,
|
||||
sizeof(s->agent_skill_requires_tools), "%s",
|
||||
SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT);
|
||||
|
||||
static int settings_path(char *out, size_t out_sz) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home == NULL || home[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
/* Multi-provider catalog — empty by default. Populated from the
|
||||
* d:user-settings Nostr event (global.agent.providers) on login.
|
||||
* If no providers are loaded, a single default provider is seeded
|
||||
* from the legacy agent_llm_base_url / agent_llm_api_key fields. */
|
||||
s->agent_provider_count = 0;
|
||||
s->agent_active_provider = -1;
|
||||
s->agent_active_provider_name[0] = '\0';
|
||||
memset(s->agent_providers, 0, sizeof(s->agent_providers));
|
||||
|
||||
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;
|
||||
}
|
||||
s->tor_enabled = TRUE;
|
||||
snprintf(s->tor_mode, sizeof(s->tor_mode), "auto");
|
||||
snprintf(s->tor_binary_path, sizeof(s->tor_binary_path), "tor");
|
||||
s->tor_attach_socks[0] = '\0';
|
||||
s->tor_attach_control[0] = '\0';
|
||||
snprintf(s->tor_data_dir, sizeof(s->tor_data_dir),
|
||||
"~/.sovereign_browser/tor");
|
||||
|
||||
n = snprintf(out, out_sz, "%s/settings.conf", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
s->fips_enabled = TRUE;
|
||||
snprintf(s->fips_mode, sizeof(s->fips_mode), "auto");
|
||||
snprintf(s->fips_binary_path, sizeof(s->fips_binary_path), "fips");
|
||||
s->fips_control_socket[0] = '\0';
|
||||
snprintf(s->fips_config_dir, sizeof(s->fips_config_dir),
|
||||
"~/.sovereign_browser/fips");
|
||||
}
|
||||
|
||||
/* ── Parsing helpers ──────────────────────────────────────────────── */
|
||||
@@ -102,150 +165,350 @@ static int parse_position(const char *value, int fallback) {
|
||||
return parse_int(value, fallback);
|
||||
}
|
||||
|
||||
/* ── Load / Save ──────────────────────────────────────────────────── */
|
||||
/* ── Position to string ───────────────────────────────────────────── */
|
||||
|
||||
static const char *position_to_string(int pos) {
|
||||
switch (pos) {
|
||||
case GTK_POS_TOP: return "top";
|
||||
case GTK_POS_BOTTOM: return "bottom";
|
||||
case GTK_POS_LEFT: return "left";
|
||||
case GTK_POS_RIGHT: return "right";
|
||||
default: return "top";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Load / Save (SQLite key_value table) ─────────────────────────── *
|
||||
* Settings are split into two groups:
|
||||
*
|
||||
* GLOBAL (stored in ~/.sovereign_browser/global.db):
|
||||
* agent_server_enabled, agent_server_port, agent_allowed_origins,
|
||||
* agent_login_timeout_ms, theme_dark, inspector_x/y/w/h
|
||||
*
|
||||
* PER-USER (stored in ~/.sovereign_browser/profiles/<pubkey>/browser.db):
|
||||
* restore_session, new_tab_url, tab_bar_position,
|
||||
* show_tab_close_buttons, middle_click_close, ctrl_tab_switch,
|
||||
* max_tabs, tab_drag_reorder, bootstrap_relays, search_engine
|
||||
*
|
||||
* The settings struct is a single in-memory singleton. Global settings
|
||||
* are loaded once at startup (from global.db) and kept in memory. Per-user
|
||||
* settings are loaded after login (from the per-user browser.db). Both
|
||||
* groups are saved when the user changes settings on the settings page.
|
||||
*/
|
||||
|
||||
void settings_load_global(void) {
|
||||
/* Read global settings from the currently-open database (global.db).
|
||||
* Defaults must already be set (call settings_set_defaults first, or
|
||||
* call settings_load_user() which sets defaults). */
|
||||
const char *val;
|
||||
|
||||
val = db_kv_get("agent_server_enabled");
|
||||
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
|
||||
|
||||
val = db_kv_get("json_viewer_enabled");
|
||||
if (val) g_settings.json_viewer_enabled = parse_bool(val, g_settings.json_viewer_enabled);
|
||||
|
||||
val = db_kv_get("agent_server_port");
|
||||
if (val) {
|
||||
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
|
||||
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
}
|
||||
|
||||
val = db_kv_get("agent_allowed_origins");
|
||||
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
|
||||
|
||||
val = db_kv_get("agent_login_timeout_ms");
|
||||
if (val) {
|
||||
g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms);
|
||||
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
|
||||
}
|
||||
|
||||
val = db_kv_get("theme_dark");
|
||||
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
|
||||
|
||||
val = db_kv_get("inspector_x");
|
||||
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
|
||||
val = db_kv_get("inspector_y");
|
||||
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
|
||||
val = db_kv_get("inspector_w");
|
||||
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
|
||||
val = db_kv_get("inspector_h");
|
||||
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
|
||||
}
|
||||
|
||||
void settings_load_user(void) {
|
||||
/* Set defaults first — this resets the entire struct, so global
|
||||
* settings loaded earlier would be lost. To preserve global settings,
|
||||
* the caller should call settings_load_global() AFTER this function
|
||||
* if both need to be (re)loaded. The normal startup flow is:
|
||||
* 1. settings_set_defaults() (via settings_load_user)
|
||||
* 2. settings_load_global() (from global.db)
|
||||
* 3. ... login ...
|
||||
* 4. settings_load_user() (from per-user browser.db, preserves
|
||||
* global settings already in memory)
|
||||
*
|
||||
* But settings_load_user() does NOT reset defaults — it only reads
|
||||
* per-user keys, leaving global keys untouched. So the correct flow
|
||||
* is:
|
||||
* 1. settings_set_defaults() — once
|
||||
* 2. settings_load_global() — from global.db
|
||||
* 3. ... login, switch to per-user db ...
|
||||
* 4. settings_load_user() — from per-user browser.db
|
||||
*/
|
||||
const char *val;
|
||||
|
||||
val = db_kv_get("restore_session");
|
||||
if (val) g_settings.restore_session = parse_bool(val, g_settings.restore_session);
|
||||
|
||||
val = db_kv_get("new_tab_url");
|
||||
if (val) snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url), "%s", val);
|
||||
|
||||
val = db_kv_get("tab_bar_position");
|
||||
if (val) g_settings.tab_bar_position = parse_position(val, g_settings.tab_bar_position);
|
||||
|
||||
val = db_kv_get("show_tab_close_buttons");
|
||||
if (val) g_settings.show_tab_close_buttons = parse_bool(val, g_settings.show_tab_close_buttons);
|
||||
|
||||
val = db_kv_get("middle_click_close");
|
||||
if (val) g_settings.middle_click_close = parse_bool(val, g_settings.middle_click_close);
|
||||
|
||||
val = db_kv_get("ctrl_tab_switch");
|
||||
if (val) g_settings.ctrl_tab_switch = parse_bool(val, g_settings.ctrl_tab_switch);
|
||||
|
||||
val = db_kv_get("max_tabs");
|
||||
if (val) {
|
||||
g_settings.max_tabs = parse_int(val, g_settings.max_tabs);
|
||||
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
|
||||
}
|
||||
|
||||
val = db_kv_get("tab_drag_reorder");
|
||||
if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder);
|
||||
|
||||
val = db_kv_get("bootstrap_relays");
|
||||
if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val);
|
||||
|
||||
val = db_kv_get("search_engine");
|
||||
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
|
||||
|
||||
val = db_kv_get("nostr_helper_apps");
|
||||
if (val) snprintf(g_settings.nostr_helper_apps,
|
||||
sizeof(g_settings.nostr_helper_apps), "%s", val);
|
||||
|
||||
val = db_kv_get("tor.enabled");
|
||||
if (val) g_settings.tor_enabled = parse_bool(val, g_settings.tor_enabled);
|
||||
val = db_kv_get("tor.mode");
|
||||
if (val) snprintf(g_settings.tor_mode, sizeof(g_settings.tor_mode), "%s", val);
|
||||
val = db_kv_get("tor.binary_path");
|
||||
if (val) snprintf(g_settings.tor_binary_path, sizeof(g_settings.tor_binary_path), "%s", val);
|
||||
val = db_kv_get("tor.attach_socks");
|
||||
if (val) snprintf(g_settings.tor_attach_socks, sizeof(g_settings.tor_attach_socks), "%s", val);
|
||||
val = db_kv_get("tor.attach_control");
|
||||
if (val) snprintf(g_settings.tor_attach_control, sizeof(g_settings.tor_attach_control), "%s", val);
|
||||
val = db_kv_get("tor.data_dir");
|
||||
if (val) snprintf(g_settings.tor_data_dir, sizeof(g_settings.tor_data_dir), "%s", val);
|
||||
|
||||
val = db_kv_get("fips.enabled");
|
||||
if (val) g_settings.fips_enabled = parse_bool(val, g_settings.fips_enabled);
|
||||
val = db_kv_get("fips.mode");
|
||||
if (val) snprintf(g_settings.fips_mode, sizeof(g_settings.fips_mode), "%s", val);
|
||||
val = db_kv_get("fips.binary_path");
|
||||
if (val) snprintf(g_settings.fips_binary_path, sizeof(g_settings.fips_binary_path), "%s", val);
|
||||
val = db_kv_get("fips.control_socket");
|
||||
if (val) snprintf(g_settings.fips_control_socket, sizeof(g_settings.fips_control_socket), "%s", val);
|
||||
val = db_kv_get("fips.config_dir");
|
||||
if (val) snprintf(g_settings.fips_config_dir, sizeof(g_settings.fips_config_dir), "%s", val);
|
||||
|
||||
/* Agent LLM provider settings — these are the "resolved" values
|
||||
* (active provider's base_url + api_key + selected model). They are
|
||||
* populated from db_kv here for local persistence, and overwritten
|
||||
* by settings_sync_merge_from_nostr() from global.agent.providers +
|
||||
* sovereign_browser.agent when a Nostr event is available. */
|
||||
val = db_kv_get("agent.llm_base_url");
|
||||
if (val) snprintf(g_settings.agent_llm_base_url, sizeof(g_settings.agent_llm_base_url), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.llm_api_key");
|
||||
if (val) snprintf(g_settings.agent_llm_api_key, sizeof(g_settings.agent_llm_api_key), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.llm_model");
|
||||
if (val) snprintf(g_settings.agent_llm_model, sizeof(g_settings.agent_llm_model), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.llm_system_prompt");
|
||||
if (val) snprintf(g_settings.agent_llm_system_prompt, sizeof(g_settings.agent_llm_system_prompt), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.max_iterations");
|
||||
if (val) g_settings.agent_max_iterations = atoi(val);
|
||||
|
||||
/* Sovereign Browser Skill fields. The template mirrors
|
||||
* agent_llm_system_prompt for backward compatibility — if the
|
||||
* legacy key is set but the new skill_template key is not, the
|
||||
* template falls back to the legacy value (or the default). */
|
||||
val = db_kv_get("agent.skill_name");
|
||||
if (val) snprintf(g_settings.agent_skill_name,
|
||||
sizeof(g_settings.agent_skill_name), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.skill_description");
|
||||
if (val) snprintf(g_settings.agent_skill_description,
|
||||
sizeof(g_settings.agent_skill_description), "%s", val);
|
||||
|
||||
val = db_kv_get("agent.skill_template");
|
||||
if (val && val[0]) {
|
||||
snprintf(g_settings.agent_skill_template,
|
||||
sizeof(g_settings.agent_skill_template), "%s", val);
|
||||
} else if (g_settings.agent_llm_system_prompt[0]) {
|
||||
/* Migrate from the legacy system_prompt field. */
|
||||
snprintf(g_settings.agent_skill_template,
|
||||
sizeof(g_settings.agent_skill_template), "%s",
|
||||
g_settings.agent_llm_system_prompt);
|
||||
}
|
||||
/* Keep the legacy alias in sync (truncates to 4096 safely). */
|
||||
if (g_settings.agent_skill_template[0]) {
|
||||
g_strlcpy(g_settings.agent_llm_system_prompt,
|
||||
g_settings.agent_skill_template,
|
||||
sizeof(g_settings.agent_llm_system_prompt));
|
||||
}
|
||||
|
||||
val = db_kv_get("agent.skill_requires_tools");
|
||||
if (val) snprintf(g_settings.agent_skill_requires_tools,
|
||||
sizeof(g_settings.agent_skill_requires_tools), "%s", val);
|
||||
|
||||
/* If no provider catalog was loaded from Nostr (agent_provider_count
|
||||
* == 0), seed a single "default" provider from the resolved
|
||||
* agent_llm_base_url / agent_llm_api_key so the agents config page
|
||||
* has something to show. */
|
||||
if (g_settings.agent_provider_count == 0) {
|
||||
agent_provider_t *p = &g_settings.agent_providers[0];
|
||||
memset(p, 0, sizeof(*p));
|
||||
snprintf(p->name, sizeof(p->name), "default");
|
||||
snprintf(p->base_url, sizeof(p->base_url), "%s",
|
||||
g_settings.agent_llm_base_url);
|
||||
snprintf(p->api_key, sizeof(p->api_key), "%s",
|
||||
g_settings.agent_llm_api_key);
|
||||
if (g_settings.agent_llm_model[0]) {
|
||||
snprintf(p->models[0], sizeof(p->models[0]), "%s",
|
||||
g_settings.agent_llm_model);
|
||||
p->model_count = 1;
|
||||
}
|
||||
g_settings.agent_provider_count = 1;
|
||||
g_settings.agent_active_provider = 0;
|
||||
snprintf(g_settings.agent_active_provider_name,
|
||||
sizeof(g_settings.agent_active_provider_name), "default");
|
||||
}
|
||||
}
|
||||
|
||||
void settings_load(void) {
|
||||
/* Backward-compatible full load: sets defaults, then loads all
|
||||
* settings from the currently-open database. Used when a single
|
||||
* database contains all settings (legacy or test scenarios). */
|
||||
settings_set_defaults(&g_settings);
|
||||
settings_load_global();
|
||||
settings_load_user();
|
||||
}
|
||||
|
||||
char path[512];
|
||||
if (settings_path(path, sizeof(path)) != 0) {
|
||||
void settings_save_global(void) {
|
||||
/* Save global settings to global.db. This uses db_kv_set_to_file()
|
||||
* which opens a separate short-lived connection, so it works even
|
||||
* when the per-user browser.db is the main open database (the normal
|
||||
* case after login). At startup, when global.db IS the main open
|
||||
* database, this also works (the separate connection is fine since
|
||||
* SQLite handles concurrent readers/writers with FULLMUTEX). */
|
||||
char gpath[512];
|
||||
global_db_path(gpath, sizeof(gpath));
|
||||
if (gpath[0] == '\0') {
|
||||
g_printerr("[settings] Failed to get global.db path for save\n");
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
char buf[32];
|
||||
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof(line), f) != NULL) {
|
||||
/* Strip newline. */
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
if (len == 0 || line[0] == '#') continue;
|
||||
db_kv_set_to_file(gpath, "agent_server_enabled",
|
||||
g_settings.agent_server_enabled ? "true" : "false");
|
||||
|
||||
/* Split key=value. */
|
||||
char *eq = strchr(line, '=');
|
||||
if (eq == NULL) continue;
|
||||
*eq = '\0';
|
||||
char *key = line;
|
||||
char *value = eq + 1;
|
||||
db_kv_set_to_file(gpath, "json_viewer_enabled",
|
||||
g_settings.json_viewer_enabled ? "true" : "false");
|
||||
|
||||
/* Trim whitespace from key. */
|
||||
while (*key == ' ' || *key == '\t') key++;
|
||||
char *kend = key + strlen(key);
|
||||
while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0';
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
|
||||
db_kv_set_to_file(gpath, "agent_server_port", buf);
|
||||
|
||||
/* Trim whitespace from value. */
|
||||
while (*value == ' ' || *value == '\t') value++;
|
||||
db_kv_set_to_file(gpath, "agent_allowed_origins",
|
||||
g_settings.agent_allowed_origins);
|
||||
|
||||
if (strcmp(key, "restore_session") == 0) {
|
||||
g_settings.restore_session = parse_bool(value, g_settings.restore_session);
|
||||
} else if (strcmp(key, "new_tab_url") == 0) {
|
||||
snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url),
|
||||
"%s", value);
|
||||
} else if (strcmp(key, "tab_bar_position") == 0) {
|
||||
g_settings.tab_bar_position = parse_position(value, g_settings.tab_bar_position);
|
||||
} else if (strcmp(key, "show_tab_close_buttons") == 0) {
|
||||
g_settings.show_tab_close_buttons = parse_bool(value, g_settings.show_tab_close_buttons);
|
||||
} else if (strcmp(key, "middle_click_close") == 0) {
|
||||
g_settings.middle_click_close = parse_bool(value, g_settings.middle_click_close);
|
||||
} else if (strcmp(key, "ctrl_tab_switch") == 0) {
|
||||
g_settings.ctrl_tab_switch = parse_bool(value, g_settings.ctrl_tab_switch);
|
||||
} else if (strcmp(key, "max_tabs") == 0) {
|
||||
g_settings.max_tabs = parse_int(value, g_settings.max_tabs);
|
||||
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
|
||||
} else if (strcmp(key, "tab_drag_reorder") == 0) {
|
||||
g_settings.tab_drag_reorder = parse_bool(value, g_settings.tab_drag_reorder);
|
||||
} else if (strcmp(key, "agent_server_enabled") == 0) {
|
||||
g_settings.agent_server_enabled = parse_bool(value, g_settings.agent_server_enabled);
|
||||
} else if (strcmp(key, "agent_server_port") == 0) {
|
||||
g_settings.agent_server_port = parse_int(value, g_settings.agent_server_port);
|
||||
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
|
||||
} else if (strcmp(key, "agent_allowed_origins") == 0) {
|
||||
snprintf(g_settings.agent_allowed_origins,
|
||||
sizeof(g_settings.agent_allowed_origins), "%s", value);
|
||||
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
|
||||
g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms);
|
||||
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
|
||||
} else if (strcmp(key, "bootstrap_relays") == 0) {
|
||||
/* The value may contain escaped newlines (\n) since the file
|
||||
* is line-based key=value. Convert \n to actual newlines. */
|
||||
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX];
|
||||
size_t ri = 0, wi = 0;
|
||||
while (value[ri] && wi < sizeof(buf) - 1) {
|
||||
if (value[ri] == '\\' && value[ri + 1] == 'n') {
|
||||
buf[wi++] = '\n';
|
||||
ri += 2;
|
||||
} else {
|
||||
buf[wi++] = value[ri++];
|
||||
}
|
||||
}
|
||||
buf[wi] = '\0';
|
||||
snprintf(g_settings.bootstrap_relays,
|
||||
sizeof(g_settings.bootstrap_relays), "%s", buf);
|
||||
}
|
||||
/* Unknown keys are silently ignored. */
|
||||
}
|
||||
fclose(f);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
|
||||
db_kv_set_to_file(gpath, "agent_login_timeout_ms", buf);
|
||||
|
||||
db_kv_set_to_file(gpath, "theme_dark",
|
||||
g_settings.theme_dark ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
|
||||
db_kv_set_to_file(gpath, "inspector_x", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
|
||||
db_kv_set_to_file(gpath, "inspector_y", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
|
||||
db_kv_set_to_file(gpath, "inspector_w", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
|
||||
db_kv_set_to_file(gpath, "inspector_h", buf);
|
||||
}
|
||||
|
||||
void settings_save_user(void) {
|
||||
/* Save per-user settings to the currently-open database (should be
|
||||
* the per-user browser.db). Called when the user changes per-user
|
||||
* settings on the settings page. */
|
||||
char buf[32];
|
||||
|
||||
db_kv_set("restore_session", g_settings.restore_session ? "true" : "false");
|
||||
db_kv_set("new_tab_url", g_settings.new_tab_url);
|
||||
db_kv_set("tab_bar_position", position_to_string(g_settings.tab_bar_position));
|
||||
db_kv_set("show_tab_close_buttons", g_settings.show_tab_close_buttons ? "true" : "false");
|
||||
db_kv_set("middle_click_close", g_settings.middle_click_close ? "true" : "false");
|
||||
db_kv_set("ctrl_tab_switch", g_settings.ctrl_tab_switch ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.max_tabs);
|
||||
db_kv_set("max_tabs", buf);
|
||||
|
||||
db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false");
|
||||
|
||||
/* Bootstrap relays are stored as-is (newlines are fine in SQLite). */
|
||||
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
|
||||
|
||||
db_kv_set("search_engine", g_settings.search_engine);
|
||||
db_kv_set("nostr_helper_apps", g_settings.nostr_helper_apps);
|
||||
|
||||
db_kv_set("tor.enabled", g_settings.tor_enabled ? "true" : "false");
|
||||
db_kv_set("tor.mode", g_settings.tor_mode);
|
||||
db_kv_set("tor.binary_path", g_settings.tor_binary_path);
|
||||
db_kv_set("tor.attach_socks", g_settings.tor_attach_socks);
|
||||
db_kv_set("tor.attach_control", g_settings.tor_attach_control);
|
||||
db_kv_set("tor.data_dir", g_settings.tor_data_dir);
|
||||
db_kv_set("fips.enabled", g_settings.fips_enabled ? "true" : "false");
|
||||
db_kv_set("fips.mode", g_settings.fips_mode);
|
||||
db_kv_set("fips.binary_path", g_settings.fips_binary_path);
|
||||
db_kv_set("fips.control_socket", g_settings.fips_control_socket);
|
||||
db_kv_set("fips.config_dir", g_settings.fips_config_dir);
|
||||
|
||||
/* Agent LLM provider settings — resolved values (active provider). */
|
||||
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
|
||||
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
|
||||
db_kv_set("agent.llm_model", g_settings.agent_llm_model);
|
||||
db_kv_set("agent.llm_system_prompt", g_settings.agent_skill_template);
|
||||
db_kv_set("agent.provider", g_settings.agent_active_provider_name);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_max_iterations);
|
||||
db_kv_set("agent.max_iterations", buf);
|
||||
|
||||
/* Sovereign Browser Skill fields. */
|
||||
db_kv_set("agent.skill_name", g_settings.agent_skill_name);
|
||||
db_kv_set("agent.skill_description", g_settings.agent_skill_description);
|
||||
db_kv_set("agent.skill_template", g_settings.agent_skill_template);
|
||||
db_kv_set("agent.skill_requires_tools",
|
||||
g_settings.agent_skill_requires_tools);
|
||||
}
|
||||
|
||||
void settings_save(void) {
|
||||
char path[512];
|
||||
if (settings_path(path, sizeof(path)) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f == NULL) {
|
||||
return;
|
||||
}
|
||||
fchmod(fileno(f), 0600);
|
||||
|
||||
const char *pos_str = "top";
|
||||
switch (g_settings.tab_bar_position) {
|
||||
case GTK_POS_TOP: pos_str = "top"; break;
|
||||
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
|
||||
case GTK_POS_LEFT: pos_str = "left"; break;
|
||||
case GTK_POS_RIGHT: pos_str = "right"; break;
|
||||
}
|
||||
|
||||
fprintf(f, "# sovereign_browser settings\n");
|
||||
fprintf(f, "restore_session=%s\n", g_settings.restore_session ? "true" : "false");
|
||||
fprintf(f, "new_tab_url=%s\n", g_settings.new_tab_url);
|
||||
fprintf(f, "tab_bar_position=%s\n", pos_str);
|
||||
fprintf(f, "show_tab_close_buttons=%s\n", g_settings.show_tab_close_buttons ? "true" : "false");
|
||||
fprintf(f, "middle_click_close=%s\n", g_settings.middle_click_close ? "true" : "false");
|
||||
fprintf(f, "ctrl_tab_switch=%s\n", g_settings.ctrl_tab_switch ? "true" : "false");
|
||||
fprintf(f, "max_tabs=%d\n", g_settings.max_tabs);
|
||||
fprintf(f, "tab_drag_reorder=%s\n", g_settings.tab_drag_reorder ? "true" : "false");
|
||||
fprintf(f, "agent_server_enabled=%s\n", g_settings.agent_server_enabled ? "true" : "false");
|
||||
fprintf(f, "agent_server_port=%d\n", g_settings.agent_server_port);
|
||||
fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins);
|
||||
fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms);
|
||||
|
||||
/* Save bootstrap relays with newlines escaped as \n (the file is
|
||||
* line-based key=value, so actual newlines would break parsing). */
|
||||
{
|
||||
char buf[SETTINGS_BOOTSTRAP_RELAYS_MAX * 2];
|
||||
size_t ri = 0, wi = 0;
|
||||
while (g_settings.bootstrap_relays[ri] &&
|
||||
wi < sizeof(buf) - 1) {
|
||||
if (g_settings.bootstrap_relays[ri] == '\n') {
|
||||
buf[wi++] = '\\';
|
||||
buf[wi++] = 'n';
|
||||
ri++;
|
||||
} else {
|
||||
buf[wi++] = g_settings.bootstrap_relays[ri++];
|
||||
}
|
||||
}
|
||||
buf[wi] = '\0';
|
||||
fprintf(f, "bootstrap_relays=%s\n", buf);
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
/* Backward-compatible full save: saves all settings to the
|
||||
* currently-open database. The settings page should call
|
||||
* settings_save_user() + settings_save_global() instead, so that
|
||||
* global settings go to global.db and per-user settings go to the
|
||||
* per-user browser.db. */
|
||||
settings_save_user();
|
||||
settings_save_global();
|
||||
}
|
||||
|
||||
const browser_settings_t *settings_get(void) {
|
||||
|
||||
121
src/settings.h
121
src/settings.h
@@ -15,7 +15,7 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com"
|
||||
#define SETTINGS_NEW_TAB_URL_DEFAULT ""
|
||||
#define SETTINGS_NEW_TAB_URL_MAX 512
|
||||
#define SETTINGS_MAX_TABS_DEFAULT 50
|
||||
#define SETTINGS_AGENT_PORT_DEFAULT 17777
|
||||
@@ -26,6 +26,40 @@ extern "C" {
|
||||
"wss://laantungir.net/relay\n" \
|
||||
"wss://relay.primal.net\n" \
|
||||
"wss://relay.damus.io"
|
||||
#define SETTINGS_SEARCH_ENGINE_MAX 64
|
||||
#define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo"
|
||||
#define SETTINGS_NOSTR_HELPER_APPS_MAX 1024
|
||||
#define SETTINGS_NOSTR_HELPER_APPS_DEFAULT \
|
||||
"njump.me|https://njump.me/{entity},Snort|https://snort.social/{entity},Jumble|https://jumble.social/{entity}"
|
||||
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
|
||||
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
|
||||
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
|
||||
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
|
||||
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
|
||||
|
||||
/* Sovereign Browser Skill — the default skill presented on the config
|
||||
* page and chat page. The template is the system prompt; the other
|
||||
* fields describe the skill for publishing as kind 31123. */
|
||||
#define SETTINGS_AGENT_SKILL_NAME_DEFAULT "Sovereign Browser Default"
|
||||
#define SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT "Default agent skill for sovereign_browser"
|
||||
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT "browser, fs, shell"
|
||||
#define SETTINGS_AGENT_SKILL_TEMPLATE_MAX 8192
|
||||
#define SETTINGS_AGENT_SKILL_NAME_MAX 128
|
||||
#define SETTINGS_AGENT_SKILL_DESCRIPTION_MAX 512
|
||||
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX 256
|
||||
|
||||
#define SETTINGS_AGENT_MAX_PROVIDERS 16
|
||||
#define SETTINGS_AGENT_MAX_MODELS 64
|
||||
#define SETTINGS_AGENT_PROVIDER_NAME_MAX 64
|
||||
|
||||
/* A single LLM provider entry in the shared global.agent.providers catalog. */
|
||||
typedef struct {
|
||||
char name[SETTINGS_AGENT_PROVIDER_NAME_MAX]; /* e.g. "ppq", "ollama-local" */
|
||||
char base_url[512];
|
||||
char api_key[512];
|
||||
char models[SETTINGS_AGENT_MAX_MODELS][128]; /* list of known model ids */
|
||||
int model_count;
|
||||
} agent_provider_t;
|
||||
|
||||
typedef struct {
|
||||
gboolean restore_session; /* restore open tabs on next launch */
|
||||
@@ -37,24 +71,101 @@ typedef struct {
|
||||
int max_tabs; /* maximum simultaneous tabs */
|
||||
gboolean tab_drag_reorder; /* allow drag to reorder tabs */
|
||||
gboolean agent_server_enabled; /* enable agent WebSocket server */
|
||||
gboolean json_viewer_enabled; /* enhance application/json pages */
|
||||
int agent_server_port; /* port for agent server */
|
||||
char agent_allowed_origins[SETTINGS_AGENT_ORIGINS_MAX]; /* "*" for any */
|
||||
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
|
||||
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
|
||||
char search_engine[SETTINGS_SEARCH_ENGINE_MAX]; /* active search engine id */
|
||||
char nostr_helper_apps[SETTINGS_NOSTR_HELPER_APPS_MAX]; /* comma-separated Name|URL pairs */
|
||||
gboolean theme_dark; /* dark mode for sovereign:// pages */
|
||||
int inspector_x; /* inspector detached window X (-1 = default) */
|
||||
int inspector_y; /* inspector detached window Y (-1 = default) */
|
||||
int inspector_w; /* inspector detached window width (-1 = default) */
|
||||
int inspector_h; /* inspector detached window height (-1 = default) */
|
||||
/* Agent LLM provider settings — "resolved" values for the active
|
||||
* provider + selected model. Populated from global.agent.providers
|
||||
* (base_url, api_key) and sovereign_browser.agent (model). */
|
||||
char agent_llm_base_url[512]; /* e.g. "https://api.openai.com/v1" */
|
||||
char agent_llm_api_key[512]; /* bearer token (may be empty for local servers) */
|
||||
char agent_llm_model[128]; /* e.g. "gpt-4o", "llama3.1" */
|
||||
char agent_llm_system_prompt[4096]; /* legacy alias for agent_skill_template (kept in sync) */
|
||||
int agent_max_iterations; /* max tool-call loop iterations (default 100) */
|
||||
|
||||
/* Sovereign Browser Skill — the default skill presented on the
|
||||
* config page and chat page. The template is the system prompt
|
||||
* (mirrored into agent_llm_system_prompt for backward compat).
|
||||
* Published as kind 31123 when the user clicks "Save as Skill". */
|
||||
char agent_skill_name[SETTINGS_AGENT_SKILL_NAME_MAX];
|
||||
char agent_skill_description[SETTINGS_AGENT_SKILL_DESCRIPTION_MAX];
|
||||
char agent_skill_template[SETTINGS_AGENT_SKILL_TEMPLATE_MAX];
|
||||
char agent_skill_requires_tools[SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX];
|
||||
|
||||
/* Multi-provider catalog (shared via global.agent in d:user-settings). */
|
||||
agent_provider_t agent_providers[SETTINGS_AGENT_MAX_PROVIDERS];
|
||||
int agent_provider_count;
|
||||
int agent_active_provider; /* index into agent_providers, -1 if none */
|
||||
char agent_active_provider_name[SETTINGS_AGENT_PROVIDER_NAME_MAX];
|
||||
|
||||
/* Tor-routed WebKit transport. */
|
||||
gboolean tor_enabled;
|
||||
char tor_mode[16]; /* auto | attach | manage */
|
||||
char tor_binary_path[256];
|
||||
char tor_attach_socks[256];
|
||||
char tor_attach_control[256];
|
||||
char tor_data_dir[512];
|
||||
|
||||
/* FIPS mesh daemon. */
|
||||
gboolean fips_enabled;
|
||||
char fips_mode[16]; /* auto | attach | manage */
|
||||
char fips_binary_path[256];
|
||||
char fips_control_socket[512];
|
||||
char fips_config_dir[512];
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
* Load settings from disk into the global singleton.
|
||||
* If the file doesn't exist or a key is missing, defaults are used.
|
||||
* Call once at startup.
|
||||
* Load all settings from the currently-open database into the global
|
||||
* singleton. Sets defaults first, then reads all keys.
|
||||
* Backward-compatible — prefer settings_load_global() + settings_load_user()
|
||||
* for the split-database flow.
|
||||
*/
|
||||
void settings_load(void);
|
||||
|
||||
/*
|
||||
* Save the current global settings to disk.
|
||||
* Load global settings (agent server, theme, inspector geometry) from
|
||||
* the currently-open database (should be global.db). Does NOT reset
|
||||
* defaults — call settings_set_defaults() first (or use the full
|
||||
* settings_load() if you need defaults reset).
|
||||
*/
|
||||
void settings_load_global(void);
|
||||
|
||||
/*
|
||||
* Load per-user settings (tabs, relays, search engine) from the
|
||||
* currently-open database (should be the per-user browser.db). Does NOT
|
||||
* reset defaults — only reads per-user keys, leaving global keys in the
|
||||
* struct untouched.
|
||||
*/
|
||||
void settings_load_user(void);
|
||||
|
||||
/*
|
||||
* Save all settings to the currently-open database.
|
||||
* Backward-compatible — prefer settings_save_global() + settings_save_user()
|
||||
* for the split-database flow.
|
||||
*/
|
||||
void settings_save(void);
|
||||
|
||||
/*
|
||||
* Save global settings to the currently-open database (should be
|
||||
* global.db). Call when global settings change.
|
||||
*/
|
||||
void settings_save_global(void);
|
||||
|
||||
/*
|
||||
* Save per-user settings to the currently-open database (should be the
|
||||
* per-user browser.db). Call when per-user settings change.
|
||||
*/
|
||||
void settings_save_user(void);
|
||||
|
||||
/*
|
||||
* Get a pointer to the global settings singleton.
|
||||
* Always valid after settings_load().
|
||||
|
||||
824
src/settings_sync.c
Normal file
824
src/settings_sync.c
Normal file
@@ -0,0 +1,824 @@
|
||||
/*
|
||||
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs whitelisted, device-independent settings + keyboard shortcut
|
||||
* bindings across all devices the user logs in on. Uses a single shared
|
||||
* kind 30078 addressable event with d-tag "user-settings" (the same event
|
||||
* used by ~/lt/client and ~/lt/didactyl). The content is NIP-44 encrypted
|
||||
* (self-to-self) JSON with a "global" namespace (shared agent provider
|
||||
* catalog under global.agent) and per-app namespaces (sovereign_browser,
|
||||
* client, didactyl, ...).
|
||||
*
|
||||
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
|
||||
* namespaces. Other apps' namespaces are preserved via read-modify-write:
|
||||
* on publish, the current d:user-settings event is fetched from the SQLite
|
||||
* cache (or relays), decrypted, the sovereign_browser + global.agent
|
||||
* namespaces are patched, and the result is re-encrypted and published.
|
||||
*
|
||||
* The publish path reuses the same pattern as bookmarks.c's
|
||||
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
|
||||
* SQLite → publish to bootstrap relays.
|
||||
*
|
||||
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
|
||||
*
|
||||
* Schema (v2):
|
||||
* {
|
||||
* "v": 2,
|
||||
* "updatedAt": <ts>,
|
||||
* "global": {
|
||||
* "agent": {
|
||||
* "providers": [ {name, base_url, api_key, models}, ... ],
|
||||
* "favorites": [ "model-id", ... ]
|
||||
* },
|
||||
* ... (zaps, ui, relays, ... — preserved, not touched by us)
|
||||
* },
|
||||
* "sovereign_browser": {
|
||||
* "agent": { provider, model, system_prompt, max_iterations },
|
||||
* "new_tab_url": "", "tab_bar_position": 0, "max_tabs": 50,
|
||||
* "bootstrap_relays": "...", "search_engine": "duckduckgo",
|
||||
* "theme_dark": false, "shortcuts": { ... }
|
||||
* },
|
||||
* "client": { ... }, // preserved
|
||||
* "didactyl": { ... } // preserved
|
||||
* }
|
||||
*/
|
||||
|
||||
#include "settings_sync.h"
|
||||
#include "settings.h"
|
||||
#include "shortcuts.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Global state ───────────────────────────────────────────────────── */
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static int g_have_signer = 0;
|
||||
|
||||
/* Debounce: a timeout source id for the deferred publish. */
|
||||
static guint g_publish_timeout_id = 0;
|
||||
|
||||
/* db_kv key for the last-synced timestamp. */
|
||||
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
|
||||
|
||||
/* ── Whitelist of syncable browser settings ─────────────────────────── */
|
||||
|
||||
/* Keys from the settings struct that should be synced under the
|
||||
* sovereign_browser namespace. Device-specific settings (agent port,
|
||||
* allowed origins, session restore, security toggles) are intentionally
|
||||
* excluded. */
|
||||
static const char *g_sync_setting_keys[] = {
|
||||
"new_tab_url",
|
||||
"tab_bar_position",
|
||||
"show_tab_close_buttons",
|
||||
"middle_click_close",
|
||||
"ctrl_tab_switch", /* master shortcuts toggle */
|
||||
"tab_drag_reorder",
|
||||
"max_tabs",
|
||||
"bootstrap_relays",
|
||||
"search_engine",
|
||||
};
|
||||
static const int g_sync_setting_count =
|
||||
(int)(sizeof(g_sync_setting_keys) / sizeof(g_sync_setting_keys[0]));
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Parse bootstrap relays from settings into an array.
|
||||
* Returns the count. Fills urls_out (pointers into relay_buf).
|
||||
* Caller must g_free(relay_buf) after use. */
|
||||
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
*relay_buf = g_strdup(s->bootstrap_relays);
|
||||
int count = 0;
|
||||
char *saveptr = NULL;
|
||||
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
|
||||
while (line != NULL && count < max) {
|
||||
while (*line == ' ' || *line == '\t') line++;
|
||||
if (line[0] != '\0' &&
|
||||
(strncmp(line, "wss://", 6) == 0 ||
|
||||
strncmp(line, "ws://", 5) == 0)) {
|
||||
urls_out[count++] = line;
|
||||
}
|
||||
line = strtok_r(NULL, "\n\r", &saveptr);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Encrypt a plaintext string with NIP-44 (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *encrypt_content(const char *plaintext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
|
||||
char *ciphertext = NULL;
|
||||
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
|
||||
&ciphertext);
|
||||
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
|
||||
g_printerr("[settings_sync] NIP-44 encrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
/* Decrypt a NIP-44 ciphertext (self-to-self).
|
||||
* Returns a newly allocated string (caller must free), or NULL on error. */
|
||||
static char *decrypt_content(const char *ciphertext) {
|
||||
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
|
||||
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
|
||||
|
||||
char *plaintext = NULL;
|
||||
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
|
||||
&plaintext);
|
||||
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
|
||||
g_printerr("[settings_sync] NIP-44 decrypt failed: %d\n", rc);
|
||||
return NULL;
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/* Check whether a kind 30078 event has a given d-tag value.
|
||||
* Returns 1 if matched, 0 otherwise. */
|
||||
static int event_has_d_tag(const cJSON *event, const char *d_value) {
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
|
||||
cJSON_IsString(t1) && strcmp(t1->valuestring, d_value) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Serialization: build sovereign_browser namespace ───────────────── */
|
||||
|
||||
/* Build the "sovereign_browser" namespace object from current settings.
|
||||
* Returns a newly allocated cJSON object (caller must delete). */
|
||||
static cJSON *build_sovereign_browser_namespace(void) {
|
||||
cJSON *sb = cJSON_CreateObject();
|
||||
|
||||
/* Browser settings whitelist (read via db_kv_get for uniform string
|
||||
* values — same approach as the original serialize_payload). */
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
const char *key = g_sync_setting_keys[i];
|
||||
const char *val = db_kv_get(key);
|
||||
if (val) {
|
||||
cJSON_AddStringToObject(sb, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-app agent config: provider, model, system_prompt (legacy),
|
||||
* max_iterations, and the Sovereign Browser Skill fields. These are
|
||||
* stored in db_kv under the agent.* keys by handle_agents_set. */
|
||||
cJSON *agent = cJSON_CreateObject();
|
||||
const char *provider = db_kv_get("agent.provider");
|
||||
const char *model = db_kv_get("agent.llm_model");
|
||||
const char *prompt = db_kv_get("agent.llm_system_prompt");
|
||||
const char *maxit = db_kv_get("agent.max_iterations");
|
||||
const char *sk_name = db_kv_get("agent.skill_name");
|
||||
const char *sk_desc = db_kv_get("agent.skill_description");
|
||||
const char *sk_tmpl = db_kv_get("agent.skill_template");
|
||||
const char *sk_tools = db_kv_get("agent.skill_requires_tools");
|
||||
cJSON_AddStringToObject(agent, "provider",
|
||||
provider ? provider : "");
|
||||
cJSON_AddStringToObject(agent, "model",
|
||||
model ? model : "");
|
||||
cJSON_AddStringToObject(agent, "system_prompt",
|
||||
prompt ? prompt : "");
|
||||
cJSON_AddNumberToObject(agent, "max_iterations",
|
||||
maxit ? (double)atol(maxit)
|
||||
: (double)SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT);
|
||||
cJSON_AddStringToObject(agent, "skill_name",
|
||||
sk_name ? sk_name : "");
|
||||
cJSON_AddStringToObject(agent, "skill_description",
|
||||
sk_desc ? sk_desc : "");
|
||||
cJSON_AddStringToObject(agent, "skill_template",
|
||||
sk_tmpl ? sk_tmpl : "");
|
||||
cJSON_AddStringToObject(agent, "skill_requires_tools",
|
||||
sk_tools ? sk_tools : "");
|
||||
cJSON_AddItemToObject(sb, "agent", agent);
|
||||
|
||||
/* Shortcuts. */
|
||||
cJSON *shortcuts_obj = shortcuts_serialize();
|
||||
cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj);
|
||||
|
||||
return sb;
|
||||
}
|
||||
|
||||
/* Build the "global.agent" namespace object from the in-memory provider
|
||||
* catalog. Returns a newly allocated cJSON object (caller must delete). */
|
||||
static cJSON *build_global_agent_namespace(void) {
|
||||
cJSON *agent = cJSON_CreateObject();
|
||||
|
||||
const browser_settings_t *s = settings_get();
|
||||
|
||||
/* providers array */
|
||||
cJSON *providers = cJSON_CreateArray();
|
||||
for (int i = 0; i < s->agent_provider_count; i++) {
|
||||
const agent_provider_t *p = &s->agent_providers[i];
|
||||
cJSON *pobj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(pobj, "name", p->name);
|
||||
cJSON_AddStringToObject(pobj, "base_url", p->base_url);
|
||||
cJSON_AddStringToObject(pobj, "api_key", p->api_key);
|
||||
cJSON *models = cJSON_CreateArray();
|
||||
for (int m = 0; m < p->model_count; m++) {
|
||||
cJSON_AddItemToArray(models, cJSON_CreateString(p->models[m]));
|
||||
}
|
||||
cJSON_AddItemToObject(pobj, "models", models);
|
||||
cJSON_AddItemToArray(providers, pobj);
|
||||
}
|
||||
cJSON_AddItemToObject(agent, "providers", providers);
|
||||
|
||||
/* favorites — for now, just the currently selected model. */
|
||||
cJSON *favorites = cJSON_CreateArray();
|
||||
if (s->agent_llm_model[0]) {
|
||||
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
|
||||
}
|
||||
cJSON_AddItemToObject(agent, "favorites", favorites);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/* ── Read-modify-write: fetch current event, patch, return payload ──── */
|
||||
|
||||
/* Fetch the current d:user-settings event from the SQLite cache.
|
||||
* Returns a newly allocated cJSON event (caller must delete), or NULL. */
|
||||
static cJSON *fetch_current_user_settings_event(void) {
|
||||
if (g_pubkey[0] == '\0') return NULL;
|
||||
/* db_get_latest_event returns the newest event of a given kind for
|
||||
* the pubkey. We then verify the d-tag is "user-settings". */
|
||||
cJSON *events = db_get_events(g_pubkey, SETTINGS_SYNC_KIND, 32);
|
||||
if (events == NULL) return NULL;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
cJSON *found = NULL;
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *ev = cJSON_GetArrayItem(events, i);
|
||||
if (event_has_d_tag(ev, SETTINGS_SYNC_D_TAG)) {
|
||||
found = cJSON_Duplicate(ev, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Decrypt the content of an event and parse it as a JSON object.
|
||||
* Returns a newly allocated cJSON object (caller must delete), or NULL. */
|
||||
static cJSON *decrypt_event_payload(const cJSON *event) {
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content)) return NULL;
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) return NULL;
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/* Build the full payload to publish, using read-modify-write:
|
||||
* 1. Fetch the current d:user-settings event from SQLite cache.
|
||||
* 2. Decrypt + parse it (or start a fresh v2 object if none).
|
||||
* 3. Patch the "sovereign_browser" and "global.agent" namespaces.
|
||||
* 4. Return the patched payload (caller must delete).
|
||||
*
|
||||
* Other namespaces (client, didactyl, global.zaps, global.ui, ...) are
|
||||
* preserved untouched. */
|
||||
static cJSON *build_publish_payload(void) {
|
||||
cJSON *payload = NULL;
|
||||
|
||||
cJSON *current = fetch_current_user_settings_event();
|
||||
if (current != NULL) {
|
||||
payload = decrypt_event_payload(current);
|
||||
cJSON_Delete(current);
|
||||
}
|
||||
|
||||
if (payload == NULL || !cJSON_IsObject(payload)) {
|
||||
/* No existing event (or decrypt failed) — start fresh. */
|
||||
if (payload) cJSON_Delete(payload);
|
||||
payload = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(payload, "v", 2);
|
||||
}
|
||||
|
||||
/* Ensure "global" object exists. */
|
||||
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
|
||||
if (!cJSON_IsObject(global)) {
|
||||
global = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(payload, "global", global);
|
||||
}
|
||||
|
||||
/* Patch global.agent (replace entirely with our view). */
|
||||
cJSON *old_agent = cJSON_GetObjectItemCaseSensitive(global, "agent");
|
||||
if (old_agent) cJSON_DeleteItemFromObjectCaseSensitive(global, "agent");
|
||||
cJSON *new_agent = build_global_agent_namespace();
|
||||
cJSON_AddItemToObject(global, "agent", new_agent);
|
||||
|
||||
/* Patch sovereign_browser namespace (replace entirely). */
|
||||
cJSON *old_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
|
||||
if (old_sb) cJSON_DeleteItemFromObjectCaseSensitive(payload, "sovereign_browser");
|
||||
cJSON *new_sb = build_sovereign_browser_namespace();
|
||||
cJSON_AddItemToObject(payload, "sovereign_browser", new_sb);
|
||||
|
||||
/* Update top-level updatedAt. */
|
||||
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "updatedAt");
|
||||
if (ts) cJSON_DeleteItemFromObjectCaseSensitive(payload, "updatedAt");
|
||||
cJSON_AddNumberToObject(payload, "updatedAt", (double)time(NULL));
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/* ── Publish (debounced) ────────────────────────────────────────────── */
|
||||
|
||||
/* The actual publish operation (no debounce). */
|
||||
static void do_publish(void) {
|
||||
if (!g_have_signer) return;
|
||||
|
||||
/* Build the payload via read-modify-write. */
|
||||
cJSON *payload = build_publish_payload();
|
||||
if (payload == NULL) {
|
||||
g_printerr("[settings_sync] Failed to build payload\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(payload);
|
||||
cJSON_Delete(payload);
|
||||
if (json == NULL) {
|
||||
g_printerr("[settings_sync] Failed to serialize payload\n");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Encrypt. */
|
||||
char *ciphertext = encrypt_content(json);
|
||||
free(json);
|
||||
if (ciphertext == NULL) return;
|
||||
|
||||
/* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(SETTINGS_SYNC_D_TAG));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON *client_tag = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
|
||||
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
|
||||
cJSON_AddItemToArray(tags, client_tag);
|
||||
|
||||
/* Create and sign the event. */
|
||||
cJSON *event = nostr_create_and_sign_event_with_signer(
|
||||
SETTINGS_SYNC_KIND, ciphertext, tags, g_signer, time(NULL));
|
||||
g_free(ciphertext);
|
||||
|
||||
if (event == NULL) {
|
||||
g_printerr("[settings_sync] Failed to create/sign event\n");
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Store in SQLite. */
|
||||
db_store_event(event);
|
||||
|
||||
/* Record the sync timestamp. */
|
||||
char ts_buf[32];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%ld", (long)time(NULL));
|
||||
db_kv_set(SYNC_TS_KEY, ts_buf);
|
||||
|
||||
/* Publish to bootstrap relays. */
|
||||
char *relay_buf = NULL;
|
||||
const char *relay_urls[32];
|
||||
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
|
||||
|
||||
if (relay_count > 0) {
|
||||
int success_count = 0;
|
||||
publish_result_t *results = synchronous_publish_event_with_progress(
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[settings_sync] Published kind %d (d:%s) to %d/%d relays\n",
|
||||
SETTINGS_SYNC_KIND, SETTINGS_SYNC_D_TAG,
|
||||
success_count, relay_count);
|
||||
} else {
|
||||
g_print("[settings_sync] No relays configured, event stored locally\n");
|
||||
}
|
||||
|
||||
g_free(relay_buf);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
/* GLib timeout callback for the debounced publish. */
|
||||
static gboolean publish_timeout_cb(gpointer data) {
|
||||
(void)data;
|
||||
g_publish_timeout_id = 0;
|
||||
do_publish();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── Merge helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Merge the "sovereign_browser" namespace from a decrypted payload into
|
||||
* local db_kv + in-memory settings. */
|
||||
static void merge_sovereign_browser_namespace(const cJSON *sb) {
|
||||
if (!cJSON_IsObject(sb)) return;
|
||||
|
||||
/* Browser settings whitelist. */
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, sb) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
|
||||
db_kv_set(item->string, item->valuestring);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-app agent config. Only overwrite local values from Nostr if
|
||||
* the local value is empty — this prevents a corrupted Nostr event
|
||||
* from overwriting good local data. The local DB is the source of
|
||||
* truth for per-app settings; Nostr is only used to bootstrap new
|
||||
* devices. */
|
||||
cJSON *agent = cJSON_GetObjectItemCaseSensitive(sb, "agent");
|
||||
if (cJSON_IsObject(agent)) {
|
||||
cJSON *j;
|
||||
const char *existing;
|
||||
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "provider");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.provider");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.provider", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "model");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.llm_model");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.llm_model", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "system_prompt");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.llm_system_prompt");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.llm_system_prompt", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "max_iterations");
|
||||
if (cJSON_IsNumber(j) && (int)j->valuedouble > 0) {
|
||||
existing = db_kv_get("agent.max_iterations");
|
||||
if (!existing || !existing[0] || atoi(existing) <= 0) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%d", (int)j->valuedouble);
|
||||
db_kv_set("agent.max_iterations", buf);
|
||||
}
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_name");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.skill_name");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.skill_name", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_description");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.skill_description");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.skill_description", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_template");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.skill_template");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.skill_template", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_requires_tools");
|
||||
if (cJSON_IsString(j) && j->valuestring[0]) {
|
||||
existing = db_kv_get("agent.skill_requires_tools");
|
||||
if (!existing || !existing[0])
|
||||
db_kv_set("agent.skill_requires_tools", j->valuestring);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shortcuts. */
|
||||
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(sb, "shortcuts");
|
||||
if (cJSON_IsObject(shortcuts_obj)) {
|
||||
shortcuts_merge_from_json(shortcuts_obj);
|
||||
}
|
||||
|
||||
/* Reload settings from db_kv into the in-memory singleton. */
|
||||
settings_load();
|
||||
}
|
||||
|
||||
/* Merge the "global.agent" namespace from a decrypted payload into the
|
||||
* in-memory provider catalog + resolved agent_llm_* fields. */
|
||||
static void merge_global_agent_namespace(const cJSON *agent_obj) {
|
||||
if (!cJSON_IsObject(agent_obj)) return;
|
||||
|
||||
browser_settings_t *s = settings_get_mutable();
|
||||
|
||||
/* providers array — only merge from Nostr if the local provider
|
||||
* catalog is empty. This prevents a corrupted Nostr event from
|
||||
* overwriting good local provider data. The local DB is the source
|
||||
* of truth; Nostr is only used to bootstrap new devices. */
|
||||
cJSON *providers = cJSON_GetObjectItemCaseSensitive(agent_obj, "providers");
|
||||
if (cJSON_IsArray(providers) && s->agent_provider_count == 0) {
|
||||
s->agent_provider_count = 0;
|
||||
int n = cJSON_GetArraySize(providers);
|
||||
if (n > SETTINGS_AGENT_MAX_PROVIDERS) n = SETTINGS_AGENT_MAX_PROVIDERS;
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *p = cJSON_GetArrayItem(providers, i);
|
||||
if (!cJSON_IsObject(p)) continue;
|
||||
agent_provider_t *dst = &s->agent_providers[i];
|
||||
memset(dst, 0, sizeof(*dst));
|
||||
|
||||
cJSON *j;
|
||||
j = cJSON_GetObjectItemCaseSensitive(p, "name");
|
||||
if (cJSON_IsString(j)) {
|
||||
snprintf(dst->name, sizeof(dst->name), "%s", j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(p, "base_url");
|
||||
if (cJSON_IsString(j)) {
|
||||
snprintf(dst->base_url, sizeof(dst->base_url), "%s",
|
||||
j->valuestring);
|
||||
}
|
||||
j = cJSON_GetObjectItemCaseSensitive(p, "api_key");
|
||||
if (cJSON_IsString(j)) {
|
||||
snprintf(dst->api_key, sizeof(dst->api_key), "%s",
|
||||
j->valuestring);
|
||||
}
|
||||
cJSON *models = cJSON_GetObjectItemCaseSensitive(p, "models");
|
||||
if (cJSON_IsArray(models)) {
|
||||
int mn = cJSON_GetArraySize(models);
|
||||
if (mn > SETTINGS_AGENT_MAX_MODELS) mn = SETTINGS_AGENT_MAX_MODELS;
|
||||
for (int m = 0; m < mn; m++) {
|
||||
cJSON *mj = cJSON_GetArrayItem(models, m);
|
||||
if (cJSON_IsString(mj)) {
|
||||
snprintf(dst->models[m], sizeof(dst->models[m]),
|
||||
"%s", mj->valuestring);
|
||||
dst->model_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
s->agent_provider_count++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolve the active provider from agent.provider (in db_kv or the
|
||||
* sovereign_browser namespace) and populate the resolved
|
||||
* agent_llm_base_url / agent_llm_api_key fields. */
|
||||
const char *provider_name = db_kv_get("agent.provider");
|
||||
s->agent_active_provider = -1;
|
||||
s->agent_active_provider_name[0] = '\0';
|
||||
if (provider_name && provider_name[0]) {
|
||||
for (int i = 0; i < s->agent_provider_count; i++) {
|
||||
if (strcmp(s->agent_providers[i].name, provider_name) == 0) {
|
||||
s->agent_active_provider = i;
|
||||
snprintf(s->agent_active_provider_name,
|
||||
sizeof(s->agent_active_provider_name), "%s",
|
||||
provider_name);
|
||||
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
|
||||
"%s", s->agent_providers[i].base_url);
|
||||
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
|
||||
"%s", s->agent_providers[i].api_key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* If no active provider was matched but we have providers, fall back
|
||||
* to the first provider so the resolved base_url/api_key are usable. */
|
||||
if (s->agent_active_provider < 0 && s->agent_provider_count > 0) {
|
||||
s->agent_active_provider = 0;
|
||||
snprintf(s->agent_active_provider_name,
|
||||
sizeof(s->agent_active_provider_name), "%s",
|
||||
s->agent_providers[0].name);
|
||||
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
|
||||
"%s", s->agent_providers[0].base_url);
|
||||
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
|
||||
"%s", s->agent_providers[0].api_key);
|
||||
}
|
||||
|
||||
/* The model comes from the per-app sovereign_browser.agent.model
|
||||
* (already in db_kv via merge_sovereign_browser_namespace, or from
|
||||
* the local settings). settings_load() will pick it up. */
|
||||
}
|
||||
|
||||
/* Migrate the old d:sovereign_browser event format (top-level "settings"
|
||||
* and "shortcuts" keys, no "sovereign_browser" namespace) into the new
|
||||
* v2 schema. Returns a newly allocated cJSON payload (caller must delete),
|
||||
* or NULL if the payload is not the old format. */
|
||||
static cJSON *migrate_legacy_payload(const cJSON *payload) {
|
||||
if (payload == NULL || !cJSON_IsObject(payload)) return NULL;
|
||||
|
||||
/* The old format has a top-level "settings" object and no
|
||||
* "sovereign_browser" namespace. */
|
||||
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
|
||||
cJSON *new_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
|
||||
if (!cJSON_IsObject(old_settings) || cJSON_IsObject(new_sb)) {
|
||||
return NULL; /* not the old format */
|
||||
}
|
||||
|
||||
/* Build a new v2 payload. */
|
||||
cJSON *v2 = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(v2, "v", 2);
|
||||
cJSON_AddNumberToObject(v2, "updatedAt", (double)time(NULL));
|
||||
|
||||
/* global.agent — seed from the old agent.* keys if present. */
|
||||
cJSON *global = cJSON_CreateObject();
|
||||
cJSON *agent = cJSON_CreateObject();
|
||||
cJSON *providers = cJSON_CreateArray();
|
||||
|
||||
/* Build a single provider from the old agent settings. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
cJSON *pobj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(pobj, "name", "default");
|
||||
cJSON_AddStringToObject(pobj, "base_url", s->agent_llm_base_url);
|
||||
cJSON_AddStringToObject(pobj, "api_key", s->agent_llm_api_key);
|
||||
cJSON *models = cJSON_CreateArray();
|
||||
if (s->agent_llm_model[0]) {
|
||||
cJSON_AddItemToArray(models, cJSON_CreateString(s->agent_llm_model));
|
||||
}
|
||||
cJSON_AddItemToObject(pobj, "models", models);
|
||||
cJSON_AddItemToArray(providers, pobj);
|
||||
cJSON_AddItemToObject(agent, "providers", providers);
|
||||
cJSON *favorites = cJSON_CreateArray();
|
||||
if (s->agent_llm_model[0]) {
|
||||
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
|
||||
}
|
||||
cJSON_AddItemToObject(agent, "favorites", favorites);
|
||||
cJSON_AddItemToObject(global, "agent", agent);
|
||||
cJSON_AddItemToObject(v2, "global", global);
|
||||
|
||||
/* sovereign_browser namespace — copy old settings + shortcuts. */
|
||||
cJSON *sb_ns = cJSON_Duplicate(old_settings, 1);
|
||||
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
|
||||
if (cJSON_IsObject(old_shortcuts)) {
|
||||
cJSON *sc_copy = cJSON_Duplicate(old_shortcuts, 1);
|
||||
cJSON_AddItemToObject(sb_ns, "shortcuts", sc_copy);
|
||||
}
|
||||
/* Add per-app agent config from the old resolved fields. */
|
||||
cJSON *sb_agent = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(sb_agent, "provider", "default");
|
||||
cJSON_AddStringToObject(sb_agent, "model", s->agent_llm_model);
|
||||
cJSON_AddStringToObject(sb_agent, "system_prompt", s->agent_llm_system_prompt);
|
||||
cJSON_AddNumberToObject(sb_agent, "max_iterations",
|
||||
(double)s->agent_max_iterations);
|
||||
cJSON_AddStringToObject(sb_agent, "skill_name", s->agent_skill_name);
|
||||
cJSON_AddStringToObject(sb_agent, "skill_description",
|
||||
s->agent_skill_description);
|
||||
cJSON_AddStringToObject(sb_agent, "skill_template", s->agent_skill_template);
|
||||
cJSON_AddStringToObject(sb_agent, "skill_requires_tools",
|
||||
s->agent_skill_requires_tools);
|
||||
cJSON_AddItemToObject(sb_ns, "agent", sb_agent);
|
||||
|
||||
cJSON_AddItemToObject(v2, "sovereign_browser", sb_ns);
|
||||
|
||||
g_print("[settings_sync] Migrated legacy d:%s payload to v2 schema\n",
|
||||
SETTINGS_SYNC_D_TAG_LEGACY);
|
||||
return v2;
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void settings_sync_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
settings_sync_init(signer, pubkey_hex);
|
||||
}
|
||||
|
||||
void settings_sync_publish(void) {
|
||||
if (!g_have_signer) return;
|
||||
|
||||
/* Cancel any pending publish and schedule a new one in 500ms. */
|
||||
if (g_publish_timeout_id > 0) {
|
||||
g_source_remove(g_publish_timeout_id);
|
||||
}
|
||||
g_publish_timeout_id = g_timeout_add(500, publish_timeout_cb, NULL);
|
||||
}
|
||||
|
||||
int settings_sync_merge_from_nostr(const void *event_cjson) {
|
||||
const cJSON *event = (const cJSON *)event_cjson;
|
||||
if (event == NULL) return -1;
|
||||
if (!g_have_signer) return -1;
|
||||
|
||||
/* Verify the kind. */
|
||||
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
if (!cJSON_IsNumber(kind) || (long)kind->valuedouble != SETTINGS_SYNC_KIND) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Verify the d-tag is "user-settings" (new) or "sovereign_browser"
|
||||
* (legacy — will be migrated). */
|
||||
int is_new = event_has_d_tag(event, SETTINGS_SYNC_D_TAG);
|
||||
int is_legacy = event_has_d_tag(event, SETTINGS_SYNC_D_TAG_LEGACY);
|
||||
if (!is_new && !is_legacy) return -1;
|
||||
|
||||
/* Get the event's created_at. */
|
||||
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
if (!cJSON_IsNumber(created_at)) return -1;
|
||||
long event_ts = (long)created_at->valuedouble;
|
||||
|
||||
/* Compare to our last-synced timestamp. We only skip the merge if the
|
||||
* Nostr event is STRICTLY OLDER than our last sync — this prevents
|
||||
* rolling back to stale data. If the timestamps are equal, we still
|
||||
* merge because the local DB might be missing fields that are in the
|
||||
* Nostr event (e.g., the API key was saved from another device and
|
||||
* the local DB was created from a partial sync with the same timestamp). */
|
||||
const char *ts_str = db_kv_get(SYNC_TS_KEY);
|
||||
long local_ts = 0;
|
||||
if (ts_str) local_ts = atol(ts_str);
|
||||
|
||||
if (event_ts < local_ts) {
|
||||
g_print("[settings_sync] Nostr event (%ld) older than local (%ld), "
|
||||
"skipping merge\n", event_ts, local_ts);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Decrypt the content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content)) return -1;
|
||||
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) return -1;
|
||||
|
||||
/* Parse the JSON payload. */
|
||||
cJSON *payload = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (payload == NULL || !cJSON_IsObject(payload)) {
|
||||
g_printerr("[settings_sync] Failed to parse decrypted payload\n");
|
||||
if (payload) cJSON_Delete(payload);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* If this is a legacy event, migrate the payload to v2. */
|
||||
if (is_legacy) {
|
||||
cJSON *migrated = migrate_legacy_payload(payload);
|
||||
if (migrated) {
|
||||
cJSON_Delete(payload);
|
||||
payload = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
/* Merge global.agent (provider catalog). */
|
||||
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
|
||||
if (cJSON_IsObject(global)) {
|
||||
cJSON *agent_obj = cJSON_GetObjectItemCaseSensitive(global, "agent");
|
||||
if (cJSON_IsObject(agent_obj)) {
|
||||
merge_global_agent_namespace(agent_obj);
|
||||
}
|
||||
}
|
||||
|
||||
/* Merge sovereign_browser namespace. */
|
||||
cJSON *sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
|
||||
if (cJSON_IsObject(sb)) {
|
||||
merge_sovereign_browser_namespace(sb);
|
||||
} else if (is_legacy) {
|
||||
/* Legacy payload that wasn't migrated (migrate_legacy_payload
|
||||
* returned NULL because it didn't have the old "settings" key).
|
||||
* Fall back to merging top-level "settings" + "shortcuts" as the
|
||||
* old code did. */
|
||||
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
|
||||
if (cJSON_IsObject(old_settings)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, old_settings) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
|
||||
db_kv_set(item->string, item->valuestring);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
settings_load();
|
||||
}
|
||||
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
|
||||
if (cJSON_IsObject(old_shortcuts)) {
|
||||
shortcuts_merge_from_json(old_shortcuts);
|
||||
}
|
||||
}
|
||||
|
||||
/* Update the sync timestamp. */
|
||||
char ts_buf[32];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
|
||||
db_kv_set(SYNC_TS_KEY, ts_buf);
|
||||
|
||||
g_print("[settings_sync] Merged settings from Nostr event (d:%s, ts=%ld)\n",
|
||||
is_legacy ? SETTINGS_SYNC_D_TAG_LEGACY : SETTINGS_SYNC_D_TAG,
|
||||
event_ts);
|
||||
|
||||
cJSON_Delete(payload);
|
||||
return 0;
|
||||
}
|
||||
88
src/settings_sync.h
Normal file
88
src/settings_sync.h
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs user-configurable, device-independent settings across all devices
|
||||
* the user logs in on. Uses a single shared kind 30078 addressable event
|
||||
* with d-tag "user-settings" (the same event used by ~/lt/client and
|
||||
* ~/lt/didactyl). The content is NIP-44 encrypted (self-to-self) JSON
|
||||
* with a "global" namespace (shared agent provider catalog) and per-app
|
||||
* namespaces (e.g. "sovereign_browser", "client", "didactyl").
|
||||
*
|
||||
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
|
||||
* namespaces; other apps' namespaces are preserved via read-modify-write.
|
||||
*
|
||||
* Device-specific settings (agent port, allowed origins, session restore,
|
||||
* security toggles) are NOT synced — they stay local only.
|
||||
*
|
||||
* See plans/cross-project-agent-sync.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef SETTINGS_SYNC_H
|
||||
#define SETTINGS_SYNC_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* nostr_signer_t is needed for the init function */
|
||||
#include "nostr_core/nostr_signer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The d-tag identifier used for the shared kind 30078 user-settings event.
|
||||
* This is shared with ~/lt/client and ~/lt/didactyl. */
|
||||
#define SETTINGS_SYNC_D_TAG "user-settings"
|
||||
|
||||
/* The legacy d-tag used before the migration to the shared event. */
|
||||
#define SETTINGS_SYNC_D_TAG_LEGACY "sovereign_browser"
|
||||
|
||||
/* The Nostr kind for arbitrary custom app data (NIP-78). */
|
||||
#define SETTINGS_SYNC_KIND 30078
|
||||
|
||||
/*
|
||||
* Initialize the settings sync module. Stores the signer + pubkey
|
||||
* references for later publish/merge operations.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
|
||||
*
|
||||
* Call after login and after shortcuts_load() / settings_load().
|
||||
*/
|
||||
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Serialize all syncable settings + shortcuts, NIP-44 encrypt to self,
|
||||
* build and sign a kind 30078 event, publish to bootstrap relays, and
|
||||
* store in SQLite. Debounced internally (500ms) so rapid edits coalesce
|
||||
* into a single publish.
|
||||
*
|
||||
* Safe to call from the main thread. No-op if no signer is available
|
||||
* (read-only / no-login mode).
|
||||
*/
|
||||
void settings_sync_publish(void);
|
||||
|
||||
/*
|
||||
* Decrypt and merge settings from a fetched kind 30078 event.
|
||||
* Called by the relay fetch thread after login. Compares the event's
|
||||
* created_at to the last-synced timestamp; if the Nostr event is newer,
|
||||
* overwrites local db_kv values and reloads in-memory bindings.
|
||||
*
|
||||
* event — a cJSON object representing a kind 30078 event with
|
||||
* d-tag "user-settings" (or the legacy "sovereign_browser"
|
||||
* d-tag, which is migrated to the new format on merge).
|
||||
*
|
||||
* Returns 0 on success, -1 on error / not applicable.
|
||||
*/
|
||||
int settings_sync_merge_from_nostr(const void *event_cjson);
|
||||
|
||||
/*
|
||||
* Update the signer reference (e.g. after switching identity).
|
||||
*/
|
||||
void settings_sync_set_signer(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SETTINGS_SYNC_H */
|
||||
272
src/shortcuts.c
Normal file
272
src/shortcuts.c
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* shortcuts.c — configurable keyboard shortcuts for sovereign_browser
|
||||
*
|
||||
* Bindings are stored in the SQLite key_value table as:
|
||||
* shortcut.<action_id> = "<GTK accelerator string>"
|
||||
* e.g. shortcut.new_tab = "<Control>t"
|
||||
*
|
||||
* On load, each stored string is parsed via gtk_accelerator_parse() into
|
||||
* a (keyval, mods) pair kept in a static array. shortcuts_lookup() does
|
||||
* a linear scan comparing incoming GdkEventKey values.
|
||||
*/
|
||||
|
||||
#include "shortcuts.h"
|
||||
#include "settings.h"
|
||||
#include "db.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
/* ── Registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
[SHORTCUT_NEW_TAB] = {
|
||||
"new_tab", "New tab",
|
||||
"Open a new tab", "<Control>t"
|
||||
},
|
||||
[SHORTCUT_NEW_WINDOW] = {
|
||||
"new_window", "New window",
|
||||
"Open a new browser window", "<Control>n"
|
||||
},
|
||||
[SHORTCUT_CLOSE_TAB] = {
|
||||
"close_tab", "Close tab",
|
||||
"Close the active tab", "<Control>w"
|
||||
},
|
||||
[SHORTCUT_FOCUS_URL] = {
|
||||
"focus_url", "Focus URL bar",
|
||||
"Focus the URL entry of the active tab", "<Control>l"
|
||||
},
|
||||
[SHORTCUT_NEXT_TAB] = {
|
||||
"next_tab", "Next tab (Ctrl+Tab)",
|
||||
"Cycle to the next tab", "<Control>Tab"
|
||||
},
|
||||
[SHORTCUT_PREV_TAB] = {
|
||||
"prev_tab", "Previous tab (Ctrl+Shift+Tab)",
|
||||
"Cycle to the previous tab", "<Control><Shift>Tab"
|
||||
},
|
||||
[SHORTCUT_NEXT_TAB_PAGEDOWN] = {
|
||||
"next_tab_pagedown", "Next tab (PageDown)",
|
||||
"Switch to the next tab", "<Control>Page_Down"
|
||||
},
|
||||
[SHORTCUT_PREV_TAB_PAGEUP] = {
|
||||
"prev_tab_pageup", "Previous tab (PageUp)",
|
||||
"Switch to the previous tab", "<Control>Page_Up"
|
||||
},
|
||||
[SHORTCUT_RELOAD] = {
|
||||
"reload", "Reload page",
|
||||
"Reload the current page", "F5"
|
||||
},
|
||||
[SHORTCUT_FORCE_RELOAD] = {
|
||||
"force_reload", "Force reload",
|
||||
"Reload bypassing cache", "<Shift>F5"
|
||||
},
|
||||
[SHORTCUT_GO_BACK] = {
|
||||
"go_back", "Go back",
|
||||
"Navigate back in history", "<Alt>Left"
|
||||
},
|
||||
[SHORTCUT_GO_FORWARD] = {
|
||||
"go_forward", "Go forward",
|
||||
"Navigate forward in history", "<Alt>Right"
|
||||
},
|
||||
[SHORTCUT_FIND] = {
|
||||
"find", "Find in page",
|
||||
"Open the find bar", "<Control>f"
|
||||
},
|
||||
[SHORTCUT_OPEN_SETTINGS] = {
|
||||
"open_settings", "Open settings",
|
||||
"Open the sovereign://settings page", "<Control>comma"
|
||||
},
|
||||
[SHORTCUT_OPEN_PROCESSES] = {
|
||||
"open_processes", "Open processes",
|
||||
"Open the sovereign://processes diagnostics page",
|
||||
"<Control><Shift>Escape"
|
||||
},
|
||||
[SHORTCUT_NEW_IDENTITY] = {
|
||||
"new_identity", "Switch identity",
|
||||
"Open the Nostr login dialog to switch identity",
|
||||
"<Control><Shift>u"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_FULLSCREEN] = {
|
||||
"toggle_fullscreen", "Toggle fullscreen",
|
||||
"Enter or exit fullscreen mode", "F11"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_INSPECTOR] = {
|
||||
"toggle_inspector", "Toggle inspector",
|
||||
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_SIDEBAR] = {
|
||||
"toggle_sidebar", "Toggle agent sidebar",
|
||||
"Show or hide the agent chat sidebar", "<Control><Shift>a"
|
||||
},
|
||||
[SHORTCUT_ZOOM_IN] = {
|
||||
"zoom_in", "Zoom in",
|
||||
"Increase the zoom level of the active tab", "<Control>plus"
|
||||
},
|
||||
[SHORTCUT_ZOOM_OUT] = {
|
||||
"zoom_out", "Zoom out",
|
||||
"Decrease the zoom level of the active tab", "<Control>minus"
|
||||
},
|
||||
[SHORTCUT_ZOOM_RESET] = {
|
||||
"zoom_reset", "Reset zoom",
|
||||
"Reset the zoom level of the active tab to 100%", "<Control>0"
|
||||
},
|
||||
};
|
||||
|
||||
/* ── In-memory parsed bindings ──────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
guint keyval;
|
||||
GdkModifierType mods;
|
||||
char accel_str[64]; /* current string, for UI + sync */
|
||||
} parsed_binding_t;
|
||||
|
||||
static parsed_binding_t g_parsed[SHORTCUT_COUNT];
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* db_kv key for an action: "shortcut.new_tab" */
|
||||
static void kv_key(shortcut_action_t a, char *buf, size_t buflen) {
|
||||
snprintf(buf, buflen, "shortcut.%s", g_registry[a].id);
|
||||
}
|
||||
|
||||
/* Parse an accelerator string into the g_parsed slot and update accel_str.
|
||||
* gtk_accelerator_parse() returns void in GTK3, so we check keyval==0
|
||||
* to detect a parse failure. */
|
||||
static int parse_and_store(shortcut_action_t a, const char *accel_str) {
|
||||
if (accel_str == NULL || accel_str[0] == '\0') return -1;
|
||||
|
||||
guint keyval = 0;
|
||||
GdkModifierType mods = 0;
|
||||
gtk_accelerator_parse(accel_str, &keyval, &mods);
|
||||
if (keyval == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_parsed[a].keyval = keyval;
|
||||
g_parsed[a].mods = mods;
|
||||
snprintf(g_parsed[a].accel_str, sizeof(g_parsed[a].accel_str),
|
||||
"%s", accel_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
void shortcuts_load(void) {
|
||||
/* Start with defaults. */
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
const char *dflt = g_registry[i].dflt;
|
||||
parse_and_store((shortcut_action_t)i, dflt);
|
||||
}
|
||||
|
||||
/* Override with stored values from db_kv. */
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
char key[64];
|
||||
kv_key((shortcut_action_t)i, key, sizeof(key));
|
||||
const char *val = db_kv_get(key);
|
||||
if (val && val[0]) {
|
||||
if (parse_and_store((shortcut_action_t)i, val) != 0) {
|
||||
/* Bad stored value — keep the default. */
|
||||
g_printerr("[shortcuts] Bad stored value for %s: %s\n",
|
||||
key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int shortcuts_lookup(GdkEventKey *event) {
|
||||
if (event == NULL) return -1;
|
||||
|
||||
/* Master toggle: if shortcuts are disabled, bail out. */
|
||||
const browser_settings_t *s = settings_get();
|
||||
if (!s->ctrl_tab_switch) return -1;
|
||||
|
||||
guint event_mods = event->state & gtk_accelerator_get_default_mod_mask();
|
||||
|
||||
/* gtk_accelerator_parse() stores lowercase keyvals for letter keys,
|
||||
* but GdkEventKey delivers the uppercase keyval when Shift is held.
|
||||
* Normalize the event keyval to lowercase for comparison. */
|
||||
guint event_keyval = gdk_keyval_to_lower(event->keyval);
|
||||
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
if (g_parsed[i].keyval == 0) continue;
|
||||
if (g_parsed[i].keyval == event_keyval &&
|
||||
g_parsed[i].mods == event_mods) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *shortcuts_get(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
|
||||
return g_parsed[action].accel_str;
|
||||
}
|
||||
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return -1;
|
||||
if (accel_str == NULL) return -1;
|
||||
|
||||
if (parse_and_store(action, accel_str) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char key[64];
|
||||
kv_key(action, key, sizeof(key));
|
||||
db_kv_set(key, g_parsed[action].accel_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void shortcuts_reset(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return;
|
||||
shortcuts_set(action, g_registry[action].dflt);
|
||||
}
|
||||
|
||||
void shortcuts_reset_all(void) {
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
shortcuts_reset((shortcut_action_t)i);
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action) {
|
||||
if (action < 0 || action >= SHORTCUT_COUNT) return NULL;
|
||||
return &g_registry[action];
|
||||
}
|
||||
|
||||
int shortcuts_action_from_id(const char *id) {
|
||||
if (id == NULL) return -1;
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
if (strcmp(g_registry[i].id, id) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ── Serialization (for NIP-78 sync) ────────────────────────────────── */
|
||||
|
||||
cJSON *shortcuts_serialize(void) {
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
for (int i = 0; i < SHORTCUT_COUNT; i++) {
|
||||
cJSON_AddStringToObject(obj, g_registry[i].id,
|
||||
g_parsed[i].accel_str);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
void shortcuts_merge_from_json(const cJSON *json) {
|
||||
if (json == NULL || !cJSON_IsObject(json)) return;
|
||||
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, json) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
int action = shortcuts_action_from_id(item->string);
|
||||
if (action < 0) continue; /* unknown key — ignore */
|
||||
|
||||
if (parse_and_store((shortcut_action_t)action,
|
||||
item->valuestring) == 0) {
|
||||
char key[64];
|
||||
kv_key((shortcut_action_t)action, key, sizeof(key));
|
||||
db_kv_set(key, g_parsed[action].accel_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/shortcuts.h
Normal file
139
src/shortcuts.h
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* shortcuts.h — configurable keyboard shortcuts for sovereign_browser
|
||||
*
|
||||
* Browser-level keyboard shortcuts (new tab, close tab, focus URL, tab
|
||||
* cycling, reload, back/forward, find, settings, fullscreen) are
|
||||
* user-configurable. Bindings are stored in the SQLite key_value table
|
||||
* as "shortcut.<action_id>" = GTK accelerator string (e.g. "<Control>t")
|
||||
* and synced across devices via NIP-78 (kind 30078) by settings_sync.c.
|
||||
*
|
||||
* The on_key_press() handler in main.c calls shortcuts_lookup() on each
|
||||
* key event and dispatches the returned action.
|
||||
*/
|
||||
|
||||
#ifndef SHORTCUTS_H
|
||||
#define SHORTCUTS_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/* Forward declaration for cJSON (used in serialize/merge functions). */
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── Action registry ────────────────────────────────────────────────── */
|
||||
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_NEW_WINDOW,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
SHORTCUT_PREV_TAB,
|
||||
SHORTCUT_NEXT_TAB_PAGEDOWN,
|
||||
SHORTCUT_PREV_TAB_PAGEUP,
|
||||
SHORTCUT_RELOAD,
|
||||
SHORTCUT_FORCE_RELOAD,
|
||||
SHORTCUT_GO_BACK,
|
||||
SHORTCUT_GO_FORWARD,
|
||||
SHORTCUT_FIND,
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_OPEN_PROCESSES,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_TOGGLE_SIDEBAR,
|
||||
SHORTCUT_ZOOM_IN,
|
||||
SHORTCUT_ZOOM_OUT,
|
||||
SHORTCUT_ZOOM_RESET,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
/* Metadata for the settings UI. */
|
||||
typedef struct {
|
||||
const char *id; /* "new_tab" — used in db_kv key and JSON */
|
||||
const char *label; /* "New tab" — shown in the settings page */
|
||||
const char *desc; /* "Open a new tab" — short description */
|
||||
const char *dflt; /* "<Control>t" — default GTK accelerator */
|
||||
} shortcut_meta_t;
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Load all bindings from the SQLite key_value table into the in-memory
|
||||
* array. Missing keys use the defaults from the registry.
|
||||
* Call once at startup after db_init() and settings_load().
|
||||
*/
|
||||
void shortcuts_load(void);
|
||||
|
||||
/*
|
||||
* Look up which action a key event matches, or -1 if none.
|
||||
* Applies gtk_accelerator_get_default_mod_mask() to the event state
|
||||
* and compares against the parsed bindings.
|
||||
*
|
||||
* If the master "shortcuts_enabled" setting is false, always returns -1.
|
||||
*/
|
||||
int shortcuts_lookup(GdkEventKey *event);
|
||||
|
||||
/*
|
||||
* Get the current accelerator string for an action (e.g. for the
|
||||
* settings UI or for Nostr sync serialization). Returns a pointer to
|
||||
* an internal buffer valid until the next shortcuts_set() call.
|
||||
*/
|
||||
const char *shortcuts_get(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Set and persist a new binding for an action.
|
||||
* accel_str — must be parseable by gtk_accelerator_parse()
|
||||
* Returns 0 on success, -1 on parse failure.
|
||||
* Updates the in-memory array immediately and writes to db_kv.
|
||||
*/
|
||||
int shortcuts_set(shortcut_action_t action, const char *accel_str);
|
||||
|
||||
/*
|
||||
* Reset an action to its default binding (and persist to db_kv).
|
||||
*/
|
||||
void shortcuts_reset(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Reset all actions to their default bindings (and persist to db_kv).
|
||||
*/
|
||||
void shortcuts_reset_all(void);
|
||||
|
||||
/*
|
||||
* Get the metadata (id, label, desc, default) for an action.
|
||||
* Returns a pointer to a static struct.
|
||||
*/
|
||||
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
|
||||
|
||||
/*
|
||||
* Look up an action by its string id (e.g. "new_tab").
|
||||
* Returns the action enum, or -1 if not found.
|
||||
*/
|
||||
int shortcuts_action_from_id(const char *id);
|
||||
|
||||
/*
|
||||
* Serialize all current bindings to a JSON object suitable for NIP-78
|
||||
* sync. Returns a newly allocated cJSON object (caller must delete).
|
||||
* Format: {"new_tab":"<Control>t","close_tab":"<Control>w",...}
|
||||
*/
|
||||
cJSON *shortcuts_serialize(void);
|
||||
|
||||
/*
|
||||
* Merge bindings from a parsed JSON object (as produced by
|
||||
* shortcuts_serialize). For each key that matches a known action id,
|
||||
* parse the accelerator and update the in-memory binding + db_kv.
|
||||
* Unknown keys are silently ignored.
|
||||
*
|
||||
* json — a cJSON object mapping action id → accelerator string
|
||||
*/
|
||||
void shortcuts_merge_from_json(const cJSON *json);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SHORTCUTS_H */
|
||||
3204
src/tab_manager.c
3204
src/tab_manager.c
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ typedef struct {
|
||||
GtkWidget *tab_label; /* composite label widget for the tab strip */
|
||||
GtkWidget *title_label; /* child of tab_label */
|
||||
GtkWidget *favicon; /* favicon image in tab_label */
|
||||
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
|
||||
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
|
||||
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
|
||||
char current_url[TAB_URL_MAX];
|
||||
char title[TAB_TITLE_MAX];
|
||||
} tab_info_t;
|
||||
@@ -44,6 +47,18 @@ void tab_manager_init(GtkContainer *parent,
|
||||
WebKitWebContext *ctx,
|
||||
GtkWindow *window);
|
||||
|
||||
/*
|
||||
* Update the WebKitWebContext that tab_manager_new_tab() and
|
||||
* sidebar_create_webview() use to create new webviews. Used during an
|
||||
* identity switch: the old context is torn down (after all tabs are
|
||||
* closed) and a fresh per-user context is built; this function updates
|
||||
* the stored pointer so subsequent new tabs use the new context.
|
||||
*
|
||||
* The caller MUST have closed all existing tabs and sidebar webviews
|
||||
* (which reference the old context) before calling this.
|
||||
*/
|
||||
void tab_manager_set_context(WebKitWebContext *ctx);
|
||||
|
||||
/*
|
||||
* Create a new tab and load the given URL (or the default new-tab URL
|
||||
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
|
||||
@@ -51,6 +66,19 @@ void tab_manager_init(GtkContainer *parent,
|
||||
*/
|
||||
int tab_manager_new_tab(const char *url);
|
||||
|
||||
/*
|
||||
* Open the tab at the given index's URL in a new browser window.
|
||||
* Creates a new top-level GtkWindow with its own notebook and a single
|
||||
* tab loading the same URL. No-op if the index is out of range.
|
||||
*/
|
||||
void tab_manager_open_in_new_window(int index);
|
||||
|
||||
/*
|
||||
* Open a new browser window with a blank tab (or the default new-tab
|
||||
* URL). Used by the Ctrl+N shortcut.
|
||||
*/
|
||||
void tab_manager_new_window_blank(void);
|
||||
|
||||
/*
|
||||
* Close the tab at the given index. If it's the last tab, the window
|
||||
* destroy handler will fire (caller is responsible for quitting).
|
||||
@@ -126,6 +154,25 @@ void tab_manager_close_to_right(int index);
|
||||
*/
|
||||
void tab_manager_close_all(void);
|
||||
|
||||
/*
|
||||
* Destroy the agent chat sidebar webview in every open window (main + aux).
|
||||
* The sidebar container itself is kept (it will be re-populated lazily on the
|
||||
* next toggle). Used during logout / identity switch so the sidebar — which
|
||||
* holds a long-lived WebKitWebView with the previous user's session — does
|
||||
* not leak the old identity's sovereign://agents/chat state across users.
|
||||
*/
|
||||
void tab_manager_destroy_sidebar_webviews(void);
|
||||
|
||||
/*
|
||||
* Temporarily suppress the "last tab closed → quit the app" behavior.
|
||||
* Set TRUE before tab_manager_close_all() during an identity switch so
|
||||
* the browser does not exit when the previous user's tabs are closed;
|
||||
* the caller opens a fresh tab for the new user afterwards. Always
|
||||
* reset to FALSE when done so normal quit-on-last-tab-close behavior
|
||||
* is restored.
|
||||
*/
|
||||
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
|
||||
|
||||
/*
|
||||
* Duplicate the tab at the given index (open a new tab with the same URL).
|
||||
*/
|
||||
@@ -142,6 +189,72 @@ void tab_manager_reload(int index);
|
||||
*/
|
||||
void tab_manager_apply_settings(void);
|
||||
|
||||
/*
|
||||
* Set the user's avatar on the far left of the tab bar. Queries the
|
||||
* kind 0 profile from SQLite for the picture URL and downloads it
|
||||
* in a background thread. Falls back to a default icon if no picture
|
||||
* is available. Call after login (when the pubkey is known).
|
||||
*
|
||||
* pubkey_hex — the user's hex pubkey (64 chars, or NULL for default)
|
||||
*/
|
||||
void tab_manager_set_avatar(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Toggle the WebKit Web Inspector for the active tab.
|
||||
* If the inspector is visible, it is closed; otherwise it is shown.
|
||||
* The inspector's detached window position and size are saved to
|
||||
* settings and restored on the next show.
|
||||
*/
|
||||
void tab_manager_toggle_inspector(void);
|
||||
|
||||
/*
|
||||
* Zoom control for the active tab's webview.
|
||||
* tab_manager_zoom_in() — multiply zoom by 1.1 (capped at 5.0)
|
||||
* tab_manager_zoom_out() — multiply zoom by 1/1.1 (floored at 0.25)
|
||||
* tab_manager_zoom_reset()— set zoom back to 1.0 (100%)
|
||||
* No-ops if there is no active tab or webview.
|
||||
*/
|
||||
void tab_manager_zoom_in(void);
|
||||
void tab_manager_zoom_out(void);
|
||||
void tab_manager_zoom_reset(void);
|
||||
|
||||
/*
|
||||
* Toggle the agent chat sidebar for the active window. The sidebar is
|
||||
* per-window (not per-tab), so it persists across tab switches within a
|
||||
* window. When showing, if the sidebar webview has not been created yet,
|
||||
* it is created (sharing the same WebKitWebContext so sovereign:// works)
|
||||
* and loads sovereign://agents/chat. When hiding, the sidebar container
|
||||
* is hidden but the webview is kept alive so chat state persists.
|
||||
*/
|
||||
void tab_manager_toggle_sidebar(void);
|
||||
|
||||
/*
|
||||
* Returns the main webview (the web page) of the active tab, never the
|
||||
* sidebar webview. This is used by agent tools so they always operate
|
||||
* on the web page even when the sidebar is open and focused.
|
||||
*/
|
||||
WebKitWebView *tab_manager_get_main_webview(void);
|
||||
|
||||
/*
|
||||
* Returns TRUE if the sidebar is currently visible for the active window.
|
||||
*/
|
||||
gboolean tab_manager_sidebar_visible(void);
|
||||
|
||||
/*
|
||||
* Returns the main window's GtkNotebook widget. Used by main.c's
|
||||
* delete-event handler to identify which tabs belong to the main window
|
||||
* (vs auxiliary windows) when closing the main window but keeping aux
|
||||
* windows alive.
|
||||
*/
|
||||
GtkWidget *tab_manager_get_main_notebook(void);
|
||||
|
||||
/*
|
||||
* Re-hide the sidebar container after gtk_widget_show_all(window).
|
||||
* Call this in main.c after show_all so the sidebar (which is packed
|
||||
* in the window-level GtkPaned) doesn't appear on startup.
|
||||
*/
|
||||
void tab_manager_hide_sidebar_after_show_all(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
281
src/tor_control.c
Normal file
281
src/tor_control.c
Normal file
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* tor_control.c — synchronous, short-timeout Tor control-protocol client
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tor_control.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define TOR_RESPONSE_MAX 8192
|
||||
|
||||
static void set_error(char *out, size_t size, const char *format, const char *detail) {
|
||||
if (out == NULL || size == 0) return;
|
||||
snprintf(out, size, format, detail ? detail : "unknown error");
|
||||
}
|
||||
|
||||
static int wait_fd(int fd, short events, int timeout_ms) {
|
||||
struct pollfd pfd;
|
||||
pfd.fd = fd;
|
||||
pfd.events = events;
|
||||
pfd.revents = 0;
|
||||
int rc;
|
||||
do { rc = poll(&pfd, 1, timeout_ms); } while (rc < 0 && errno == EINTR);
|
||||
if (rc <= 0) return -1;
|
||||
return (pfd.revents & events) ? 0 : -1;
|
||||
}
|
||||
|
||||
static int finish_nonblocking_connect(int fd, int timeout_ms) {
|
||||
if (wait_fd(fd, POLLOUT, timeout_ms) != 0) return -1;
|
||||
int socket_error = 0;
|
||||
socklen_t len = sizeof(socket_error);
|
||||
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &len) != 0 ||
|
||||
socket_error != 0) {
|
||||
if (socket_error) errno = socket_error;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int connect_unix(const char *path, int timeout_ms) {
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return -1;
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
if (strlen(path) >= sizeof(addr.sun_path)) {
|
||||
close(fd);
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path);
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
|
||||
if (rc != 0 && errno == EINPROGRESS) rc = finish_nonblocking_connect(fd, timeout_ms);
|
||||
if (rc != 0) { close(fd); return -1; }
|
||||
fcntl(fd, F_SETFL, flags);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int connect_tcp(const char *endpoint, int timeout_ms) {
|
||||
char host[256] = "127.0.0.1";
|
||||
char port[16] = "9051";
|
||||
const char *colon = strrchr(endpoint, ':');
|
||||
if (colon != NULL) {
|
||||
size_t hlen = (size_t)(colon - endpoint);
|
||||
if (hlen > 0 && hlen < sizeof(host)) {
|
||||
memcpy(host, endpoint, hlen);
|
||||
host[hlen] = '\0';
|
||||
}
|
||||
snprintf(port, sizeof(port), "%s", colon + 1);
|
||||
} else if (endpoint[0]) {
|
||||
snprintf(host, sizeof(host), "%s", endpoint);
|
||||
}
|
||||
|
||||
struct addrinfo hints, *result = NULL, *it;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host, port, &hints, &result) != 0) return -1;
|
||||
int fd = -1;
|
||||
for (it = result; it; it = it->ai_next) {
|
||||
fd = socket(it->ai_family, it->ai_socktype | SOCK_CLOEXEC, it->ai_protocol);
|
||||
if (fd < 0) continue;
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
int rc = connect(fd, it->ai_addr, it->ai_addrlen);
|
||||
if (rc != 0 && errno == EINPROGRESS) rc = finish_nonblocking_connect(fd, timeout_ms);
|
||||
if (rc == 0) { fcntl(fd, F_SETFL, flags); break; }
|
||||
close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
freeaddrinfo(result);
|
||||
return fd;
|
||||
}
|
||||
|
||||
int tor_control_connect(const char *endpoint, int timeout_ms,
|
||||
char *error, size_t error_size) {
|
||||
if (endpoint == NULL || endpoint[0] == '\0') {
|
||||
set_error(error, error_size, "%s", "empty Tor endpoint");
|
||||
return -1;
|
||||
}
|
||||
const char *unix_path = NULL;
|
||||
if (strncmp(endpoint, "unix:", 5) == 0) unix_path = endpoint + 5;
|
||||
else if (endpoint[0] == '/') unix_path = endpoint;
|
||||
int fd = unix_path ? connect_unix(unix_path, timeout_ms)
|
||||
: connect_tcp(endpoint, timeout_ms);
|
||||
if (fd < 0) set_error(error, error_size, "Tor connect failed: %s", strerror(errno));
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int write_all(int fd, const char *data, size_t length, int timeout_ms) {
|
||||
size_t sent = 0;
|
||||
while (sent < length) {
|
||||
if (wait_fd(fd, POLLOUT, timeout_ms) != 0) return -1;
|
||||
ssize_t n = send(fd, data + sent, length - sent, MSG_NOSIGNAL);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) return -1;
|
||||
sent += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_response(int fd, char *response, size_t response_size,
|
||||
char *error, size_t error_size) {
|
||||
size_t used = 0;
|
||||
response[0] = '\0';
|
||||
while (used + 1 < response_size) {
|
||||
if (wait_fd(fd, POLLIN, 1500) != 0) {
|
||||
set_error(error, error_size, "%s", "Tor control response timed out");
|
||||
return -1;
|
||||
}
|
||||
ssize_t n = recv(fd, response + used, response_size - used - 1, 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) {
|
||||
set_error(error, error_size, "%s", "Tor closed control connection");
|
||||
return -1;
|
||||
}
|
||||
used += (size_t)n;
|
||||
response[used] = '\0';
|
||||
|
||||
char *line = response;
|
||||
while (line && *line) {
|
||||
char *end = strstr(line, "\r\n");
|
||||
if (!end) break;
|
||||
if (strlen(line) >= 4 && line[3] == ' ') {
|
||||
int code = atoi(line);
|
||||
if (code == 250) return 0;
|
||||
char saved = *end;
|
||||
*end = '\0';
|
||||
set_error(error, error_size, "Tor control error: %s", line);
|
||||
*end = saved;
|
||||
return -1;
|
||||
}
|
||||
line = end + 2;
|
||||
}
|
||||
}
|
||||
set_error(error, error_size, "%s", "Tor control response too large");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int command(int fd, const char *command_text, char *response,
|
||||
size_t response_size, char *error, size_t error_size) {
|
||||
char *wire = g_strdup_printf("%s\r\n", command_text);
|
||||
int rc = write_all(fd, wire, strlen(wire), 1500);
|
||||
g_free(wire);
|
||||
if (rc != 0) {
|
||||
set_error(error, error_size, "Tor control write failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
return read_response(fd, response, response_size, error, error_size);
|
||||
}
|
||||
|
||||
int tor_control_authenticate(int fd, const char *cookie_path,
|
||||
char *error, size_t error_size) {
|
||||
char command_text[1024];
|
||||
if (cookie_path != NULL && cookie_path[0] != '\0') {
|
||||
gchar *cookie = NULL;
|
||||
gsize length = 0;
|
||||
GError *gerror = NULL;
|
||||
if (!g_file_get_contents(cookie_path, &cookie, &length, &gerror)) {
|
||||
set_error(error, error_size, "Cannot read Tor cookie: %s",
|
||||
gerror ? gerror->message : "unknown error");
|
||||
g_clear_error(&gerror);
|
||||
return -1;
|
||||
}
|
||||
GString *hex = g_string_sized_new(length * 2);
|
||||
for (gsize i = 0; i < length; i++)
|
||||
g_string_append_printf(hex, "%02X", (unsigned char)cookie[i]);
|
||||
g_free(cookie);
|
||||
snprintf(command_text, sizeof(command_text), "AUTHENTICATE %s", hex->str);
|
||||
g_string_free(hex, TRUE);
|
||||
} else {
|
||||
snprintf(command_text, sizeof(command_text), "AUTHENTICATE");
|
||||
}
|
||||
char response[TOR_RESPONSE_MAX];
|
||||
return command(fd, command_text, response, sizeof(response), error, error_size);
|
||||
}
|
||||
|
||||
int tor_control_get_bootstrap(int fd, int *progress,
|
||||
char *summary, size_t summary_size,
|
||||
char *error, size_t error_size) {
|
||||
char response[TOR_RESPONSE_MAX];
|
||||
if (command(fd, "GETINFO status/bootstrap-phase", response,
|
||||
sizeof(response), error, error_size) != 0) return -1;
|
||||
char *marker = strstr(response, "PROGRESS=");
|
||||
if (marker == NULL) {
|
||||
set_error(error, error_size, "%s", "Tor bootstrap response lacks PROGRESS");
|
||||
return -1;
|
||||
}
|
||||
int value = atoi(marker + 9);
|
||||
if (progress) *progress = value;
|
||||
if (summary && summary_size) {
|
||||
char *end = strstr(marker, "\r\n");
|
||||
size_t n = end ? (size_t)(end - marker) : strlen(marker);
|
||||
if (n >= summary_size) n = summary_size - 1;
|
||||
memcpy(summary, marker, n);
|
||||
summary[n] = '\0';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int signal_command(int fd, const char *signal_name,
|
||||
char *error, size_t error_size) {
|
||||
char cmd[64], response[TOR_RESPONSE_MAX];
|
||||
snprintf(cmd, sizeof(cmd), "SIGNAL %s", signal_name);
|
||||
return command(fd, cmd, response, sizeof(response), error, error_size);
|
||||
}
|
||||
|
||||
int tor_control_signal_newnym(int fd, char *error, size_t error_size) {
|
||||
return signal_command(fd, "NEWNYM", error, error_size);
|
||||
}
|
||||
|
||||
int tor_control_signal_term(int fd, char *error, size_t error_size) {
|
||||
return signal_command(fd, "TERM", error, error_size);
|
||||
}
|
||||
|
||||
void tor_control_close(int *fd) {
|
||||
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
|
||||
}
|
||||
|
||||
gboolean tor_control_endpoint_available(const char *endpoint, int timeout_ms) {
|
||||
int fd = tor_control_connect(endpoint, timeout_ms, NULL, 0);
|
||||
if (fd < 0) return FALSE;
|
||||
close(fd);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean tor_socks_endpoint_available(const char *endpoint, int timeout_ms) {
|
||||
if (endpoint == NULL || endpoint[0] == '\0') return FALSE;
|
||||
const char *plain = endpoint;
|
||||
if (strncmp(plain, "socks5://", 9) == 0) plain += 9;
|
||||
if (strncmp(plain, "unix:", 5) == 0 || plain[0] == '/') {
|
||||
const char *path = strncmp(plain, "unix:", 5) == 0 ? plain + 5 : plain;
|
||||
int fd = connect_unix(path, timeout_ms);
|
||||
if (fd < 0) return FALSE;
|
||||
close(fd);
|
||||
return TRUE;
|
||||
}
|
||||
int fd = connect_tcp(plain, timeout_ms);
|
||||
if (fd < 0) return FALSE;
|
||||
unsigned char hello[] = { 0x05, 0x01, 0x00 };
|
||||
unsigned char reply[2];
|
||||
gboolean ok = FALSE;
|
||||
if (write_all(fd, (const char *)hello, sizeof(hello), timeout_ms) == 0 &&
|
||||
wait_fd(fd, POLLIN, timeout_ms) == 0 && recv(fd, reply, 2, 0) == 2 &&
|
||||
reply[0] == 0x05 && reply[1] != 0xff) ok = TRUE;
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
33
src/tor_control.h
Normal file
33
src/tor_control.h
Normal file
@@ -0,0 +1,33 @@
|
||||
/* tor_control.h — minimal Tor control-protocol client */
|
||||
|
||||
#ifndef TOR_CONTROL_H
|
||||
#define TOR_CONTROL_H
|
||||
|
||||
#include <glib.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Endpoints may be unix:/path, a bare absolute Unix path, or host:port. */
|
||||
int tor_control_connect(const char *endpoint, int timeout_ms,
|
||||
char *error, size_t error_size);
|
||||
int tor_control_authenticate(int fd, const char *cookie_path,
|
||||
char *error, size_t error_size);
|
||||
int tor_control_get_bootstrap(int fd, int *progress,
|
||||
char *summary, size_t summary_size,
|
||||
char *error, size_t error_size);
|
||||
int tor_control_signal_newnym(int fd, char *error, size_t error_size);
|
||||
int tor_control_signal_term(int fd, char *error, size_t error_size);
|
||||
void tor_control_close(int *fd);
|
||||
|
||||
/* Lightweight discovery helpers. */
|
||||
gboolean tor_control_endpoint_available(const char *endpoint, int timeout_ms);
|
||||
gboolean tor_socks_endpoint_available(const char *endpoint, int timeout_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* TOR_CONTROL_H */
|
||||
291
src/tor_scheme.c
Normal file
291
src/tor_scheme.c
Normal file
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* tor_scheme.c — asynchronous Tor-backed WebKitGTK handler for tor://
|
||||
*/
|
||||
|
||||
#include "tor_scheme.h"
|
||||
|
||||
#include "net_services.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <glib.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TOR_FETCH_MAX_BYTES (16 * 1024 * 1024)
|
||||
#define TOR_FETCH_TIMEOUT_SECONDS 30L
|
||||
#define TOR_CONNECT_TIMEOUT_SECONDS 15L
|
||||
|
||||
typedef struct {
|
||||
WebKitURISchemeRequest *request;
|
||||
char *target_url;
|
||||
char *proxy_url;
|
||||
GString *response_body;
|
||||
char *content_type;
|
||||
char *error_html;
|
||||
} tor_job_t;
|
||||
|
||||
typedef struct {
|
||||
GString *body;
|
||||
gboolean overflow;
|
||||
} tor_body_t;
|
||||
|
||||
static char *html_escape(const char *input) {
|
||||
if (!input) return g_strdup("");
|
||||
return g_markup_escape_text(input, -1);
|
||||
}
|
||||
|
||||
static void respond_html(WebKitURISchemeRequest *request, char *html) {
|
||||
gsize len = strlen(html);
|
||||
GInputStream *stream = g_memory_input_stream_new_from_data(html, len, g_free);
|
||||
webkit_uri_scheme_request_finish(request, stream, len, "text/html");
|
||||
g_object_unref(stream);
|
||||
}
|
||||
|
||||
static void respond_error_page(WebKitURISchemeRequest *request,
|
||||
const char *title,
|
||||
const char *message) {
|
||||
char *safe_title = html_escape(title ? title : "Error");
|
||||
char *safe_message = html_escape(message ? message : "Request failed.");
|
||||
char *html = g_strdup_printf(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">"
|
||||
"<title>%s</title></head><body>"
|
||||
"<h1>%s</h1><p>%s</p></body></html>",
|
||||
safe_title, safe_title, safe_message);
|
||||
respond_html(request, html);
|
||||
g_free(safe_title);
|
||||
g_free(safe_message);
|
||||
}
|
||||
|
||||
static gboolean host_is_onion(const char *host) {
|
||||
return host && *host && g_str_has_suffix(host, ".onion");
|
||||
}
|
||||
|
||||
static char *proxy_to_socks5h(const char *endpoint) {
|
||||
if (!endpoint || endpoint[0] == '\0') return NULL;
|
||||
if (g_str_has_prefix(endpoint, "socks5h://")) return g_strdup(endpoint);
|
||||
if (!g_str_has_prefix(endpoint, "socks5://")) return NULL;
|
||||
return g_strdup_printf("socks5h://%s", endpoint + strlen("socks5://"));
|
||||
}
|
||||
|
||||
static char *build_target_http_url(const char *uri, char **error_message) {
|
||||
GError *error = NULL;
|
||||
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_PARSE_RELAXED, &error);
|
||||
if (!parsed) {
|
||||
if (error_message)
|
||||
*error_message = g_strdup(error ? error->message : "Invalid tor:// URI");
|
||||
g_clear_error(&error);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *host = g_uri_get_host(parsed);
|
||||
gint port = g_uri_get_port(parsed);
|
||||
const char *path = g_uri_get_path(parsed);
|
||||
const char *query = g_uri_get_query(parsed);
|
||||
|
||||
if (!host_is_onion(host)) {
|
||||
if (error_message) {
|
||||
*error_message = g_strdup("tor:// only supports .onion hostnames in Phase 1");
|
||||
}
|
||||
g_uri_unref(parsed);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!path || path[0] == '\0') path = "/";
|
||||
|
||||
/* Phase 1: always map tor:// to http:// over Tor SOCKS. HTTPS-over-onion
|
||||
* support can be added in a future phase with an explicit signal. */
|
||||
char *target = NULL;
|
||||
if (port > 0) {
|
||||
target = query
|
||||
? g_strdup_printf("http://%s:%d%s?%s", host, port, path, query)
|
||||
: g_strdup_printf("http://%s:%d%s", host, port, path);
|
||||
} else {
|
||||
target = query
|
||||
? g_strdup_printf("http://%s%s?%s", host, path, query)
|
||||
: g_strdup_printf("http://%s%s", host, path);
|
||||
}
|
||||
|
||||
g_uri_unref(parsed);
|
||||
return target;
|
||||
}
|
||||
|
||||
static size_t tor_write_bounded(void *data, size_t size, size_t nmemb, void *user_data) {
|
||||
tor_body_t *body = user_data;
|
||||
size_t bytes = size * nmemb;
|
||||
if (body->body->len + bytes > TOR_FETCH_MAX_BYTES) {
|
||||
body->overflow = TRUE;
|
||||
return 0;
|
||||
}
|
||||
g_string_append_len(body->body, data, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static size_t tor_header_capture(void *data, size_t size, size_t nmemb, void *user_data) {
|
||||
tor_job_t *job = user_data;
|
||||
size_t bytes = size * nmemb;
|
||||
const char *line = (const char *)data;
|
||||
|
||||
if (job->content_type != NULL || bytes < strlen("Content-Type:")) return bytes;
|
||||
|
||||
if (g_ascii_strncasecmp(line, "Content-Type:", strlen("Content-Type:")) == 0) {
|
||||
const char *value = line + strlen("Content-Type:");
|
||||
while (*value == ' ' || *value == '\t') value++;
|
||||
const char *end = line + bytes;
|
||||
while (end > value && (end[-1] == '\r' || end[-1] == '\n' ||
|
||||
end[-1] == ' ' || end[-1] == '\t')) {
|
||||
end--;
|
||||
}
|
||||
if (end > value) job->content_type = g_strndup(value, (gsize)(end - value));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static gpointer tor_scheme_worker(gpointer data) {
|
||||
tor_job_t *job = data;
|
||||
|
||||
CURL *curl = curl_easy_init();
|
||||
tor_body_t body = { g_string_new(NULL), FALSE };
|
||||
|
||||
if (!curl) {
|
||||
job->error_html = g_strdup(
|
||||
"Failed to load .onion site: Could not initialize HTTP client.");
|
||||
g_string_free(body.body, TRUE);
|
||||
return job;
|
||||
}
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, job->target_url);
|
||||
curl_easy_setopt(curl, CURLOPT_PROXY, job->proxy_url);
|
||||
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS5_HOSTNAME);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, TOR_FETCH_TIMEOUT_SECONDS);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, TOR_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/tor-scheme");
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, tor_write_bounded);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, tor_header_capture);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, job);
|
||||
|
||||
CURLcode rc = curl_easy_perform(curl);
|
||||
if (rc != CURLE_OK) {
|
||||
const char *err = body.overflow
|
||||
? "response exceeded maximum size"
|
||||
: curl_easy_strerror(rc);
|
||||
char *safe_err = html_escape(err);
|
||||
job->error_html = g_strdup_printf("Failed to load .onion site: %s", safe_err);
|
||||
g_free(safe_err);
|
||||
g_string_free(body.body, TRUE);
|
||||
curl_easy_cleanup(curl);
|
||||
return job;
|
||||
}
|
||||
|
||||
job->response_body = body.body;
|
||||
if (!job->content_type || job->content_type[0] == '\0') {
|
||||
g_free(job->content_type);
|
||||
job->content_type = g_strdup("text/html");
|
||||
}
|
||||
|
||||
curl_easy_cleanup(curl);
|
||||
return job;
|
||||
}
|
||||
|
||||
static void tor_job_free(tor_job_t *job) {
|
||||
if (!job) return;
|
||||
g_clear_object(&job->request);
|
||||
g_free(job->target_url);
|
||||
g_free(job->proxy_url);
|
||||
if (job->response_body) g_string_free(job->response_body, TRUE);
|
||||
g_free(job->content_type);
|
||||
g_free(job->error_html);
|
||||
g_free(job);
|
||||
}
|
||||
|
||||
static void tor_scheme_worker_done(GObject *source_object,
|
||||
GAsyncResult *result,
|
||||
gpointer user_data) {
|
||||
(void)source_object;
|
||||
(void)user_data;
|
||||
|
||||
GTask *task = G_TASK(result);
|
||||
tor_job_t *job = g_task_propagate_pointer(task, NULL);
|
||||
|
||||
if (job->error_html) {
|
||||
respond_error_page(job->request, "Tor request failed", job->error_html);
|
||||
tor_job_free(job);
|
||||
return;
|
||||
}
|
||||
|
||||
gsize len = job->response_body ? job->response_body->len : 0;
|
||||
const char *data = (job->response_body && len > 0) ? job->response_body->str : "";
|
||||
GInputStream *stream = g_memory_input_stream_new_from_data(g_memdup2(data, len),
|
||||
len,
|
||||
g_free);
|
||||
webkit_uri_scheme_request_finish(job->request,
|
||||
stream,
|
||||
len,
|
||||
job->content_type ? job->content_type : "text/html");
|
||||
g_object_unref(stream);
|
||||
tor_job_free(job);
|
||||
}
|
||||
|
||||
static void tor_task_thread(GTask *task,
|
||||
gpointer source_object,
|
||||
gpointer task_data,
|
||||
GCancellable *cancellable) {
|
||||
(void)source_object;
|
||||
(void)cancellable;
|
||||
g_task_return_pointer(task, tor_scheme_worker(task_data), NULL);
|
||||
}
|
||||
|
||||
static void on_tor_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
|
||||
(void)user_data;
|
||||
|
||||
const char *uri = webkit_uri_scheme_request_get_uri(request);
|
||||
|
||||
char *url_error = NULL;
|
||||
char *target_url = build_target_http_url(uri, &url_error);
|
||||
if (!target_url) {
|
||||
respond_error_page(request,
|
||||
"Invalid .onion URL",
|
||||
url_error ? url_error : "Invalid tor:// URL.");
|
||||
g_free(url_error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!net_service_is_ready(NET_SERVICE_TOR)) {
|
||||
respond_error_page(request,
|
||||
"Tor unavailable",
|
||||
"Tor is not ready. Please wait for Tor to bootstrap and try again.");
|
||||
g_free(target_url);
|
||||
return;
|
||||
}
|
||||
|
||||
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
|
||||
const char *endpoint = tor ? tor->status.tor.socks_endpoint : NULL;
|
||||
char *proxy_url = proxy_to_socks5h(endpoint);
|
||||
if (!proxy_url) {
|
||||
respond_error_page(request,
|
||||
"Tor unavailable",
|
||||
"Tor SOCKS endpoint is not available yet. Please try again shortly.");
|
||||
g_free(target_url);
|
||||
return;
|
||||
}
|
||||
|
||||
tor_job_t *job = g_new0(tor_job_t, 1);
|
||||
job->request = g_object_ref(request);
|
||||
job->target_url = target_url;
|
||||
job->proxy_url = proxy_url;
|
||||
|
||||
GTask *task = g_task_new(NULL, NULL, tor_scheme_worker_done, NULL);
|
||||
g_task_set_task_data(task, job, NULL);
|
||||
g_task_run_in_thread(task, tor_task_thread);
|
||||
g_object_unref(task);
|
||||
}
|
||||
|
||||
void tor_scheme_register(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
webkit_web_context_register_uri_scheme(ctx, "tor", on_tor_scheme, NULL, NULL);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user