Compare commits
36 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 |
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
|
||||
|
||||
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/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.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. |
|
||||
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.
|
||||
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
|
||||
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).
|
||||
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).
|
||||
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,6 +516,13 @@ 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);
|
||||
g_print("[agent-login] Logged out\n");
|
||||
@@ -466,6 +530,12 @@ cJSON *agent_logout(void) {
|
||||
}
|
||||
|
||||
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\":{}}"},
|
||||
@@ -548,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();
|
||||
|
||||
@@ -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 */
|
||||
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 */
|
||||
@@ -11,9 +11,12 @@
|
||||
#include "agent_login.h"
|
||||
#include "agent_snapshot.h"
|
||||
#include "agent_server.h"
|
||||
#include "agent_fs_tools.h"
|
||||
#include "tab_manager.h"
|
||||
#include "settings.h"
|
||||
#include "search.h"
|
||||
#include "nostr_url.h"
|
||||
#include "process_info.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -59,8 +62,44 @@ static gboolean get_bool_param(cJSON *params, const char *key, gboolean fallback
|
||||
|
||||
/* ── Normalize URL (same logic as tab_manager.c) ──────────────────── */
|
||||
|
||||
static char *normalize_fips_url(const char *input) {
|
||||
const char *rest = input + 7;
|
||||
const char *suffix = strpbrk(rest, "/?#");
|
||||
size_t authority_len = suffix ? (size_t)(suffix - rest) : strlen(rest);
|
||||
if (authority_len == 0) return NULL;
|
||||
char *authority = g_strndup(rest, authority_len);
|
||||
char *result;
|
||||
if (g_str_has_suffix(authority, ".fips")) {
|
||||
result = g_strdup_printf("http://%s%s", authority, suffix ? suffix : "");
|
||||
} else {
|
||||
char *colon = strrchr(authority, ':');
|
||||
if (colon && strchr(authority, ':') == colon) {
|
||||
*colon = '\0';
|
||||
result = g_strdup_printf("http://%s.fips:%s%s", authority,
|
||||
colon + 1, suffix ? suffix : "");
|
||||
} else {
|
||||
result = g_strdup_printf("http://%s.fips%s", authority,
|
||||
suffix ? suffix : "");
|
||||
}
|
||||
}
|
||||
g_free(authority);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char *normalize_url(const char *input) {
|
||||
if (input == NULL || input[0] == '\0') return NULL;
|
||||
|
||||
/* MCP open must apply the same fips:// shorthand as the interactive
|
||||
* URL bar; otherwise WebKit sees an unregistered custom scheme. */
|
||||
if (strncmp(input, "fips://", 7) == 0)
|
||||
return normalize_fips_url(input);
|
||||
|
||||
/* Keep MCP/agent navigation consistent with the interactive URL bar. */
|
||||
nostr_entity_type_t entity_type = nostr_url_detect(input);
|
||||
if (entity_type != NOSTR_ENTITY_NONE) {
|
||||
return nostr_url_normalize(input);
|
||||
}
|
||||
|
||||
/* If it looks like a URL, normalize it. */
|
||||
if (search_is_url(input)) {
|
||||
if (strstr(input, "://") != NULL) return g_strdup(input);
|
||||
@@ -72,11 +111,14 @@ static char *normalize_url(const char *input) {
|
||||
return search_build_search_url(input);
|
||||
}
|
||||
|
||||
/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */
|
||||
/* ── Get active webview (or NULL if not logged in / no tabs) ────────
|
||||
* When the agent sidebar is open, the "active" webview (the one with
|
||||
* focus) might be the sidebar chat page. Agent tools must always
|
||||
* operate on the MAIN webview (the web page), so we use
|
||||
* tab_manager_get_main_webview() which never returns the sidebar. */
|
||||
|
||||
static WebKitWebView *get_active_webview(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
return tab ? tab->webview : NULL;
|
||||
return tab_manager_get_main_webview();
|
||||
}
|
||||
|
||||
/* ── Coordinate-based click via GDK event synthesis ────────────────── */
|
||||
@@ -407,6 +449,29 @@ static cJSON *tool_switch_identity(cJSON *params) {
|
||||
|
||||
/* ── Navigation tools ─────────────────────────────────────────────── */
|
||||
|
||||
/* Idle callback data for main-thread webview navigation. WebKitGTK is
|
||||
* NOT thread-safe — webkit_web_view_load_uri() must be called on the
|
||||
* GTK main thread. The agent loop runs on a background thread, so we
|
||||
* hop to the main thread via g_idle_add() to perform the navigation.
|
||||
* (Issue 3: calling webkit_web_view_load_uri() directly from the
|
||||
* agent-loop background thread caused an "Aborted" segfault.) */
|
||||
typedef struct {
|
||||
WebKitWebView *webview;
|
||||
char *url;
|
||||
} load_uri_idle_t;
|
||||
|
||||
static gboolean load_uri_idle(gpointer user_data) {
|
||||
load_uri_idle_t *ctx = (load_uri_idle_t *)user_data;
|
||||
if (ctx && ctx->webview && ctx->url) {
|
||||
webkit_web_view_load_uri(ctx->webview, ctx->url);
|
||||
}
|
||||
if (ctx) {
|
||||
g_free(ctx->url);
|
||||
g_free(ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static cJSON *tool_open(cJSON *params) {
|
||||
const char *url = get_string_param(params, "url");
|
||||
if (!url || !url[0]) return make_error("MISSING_PARAM", "Provide 'url'");
|
||||
@@ -414,24 +479,79 @@ static cJSON *tool_open(cJSON *params) {
|
||||
char *normalized = normalize_url(url);
|
||||
if (!normalized) return make_error("INVALID_URL", "Invalid URL");
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (!tab) {
|
||||
/* Always operate on the MAIN webview (the web page), never the
|
||||
* sidebar chat webview. tab_manager_get_main_webview() returns
|
||||
* the active tab's webview directly, never the sidebar. */
|
||||
WebKitWebView *wv = tab_manager_get_main_webview();
|
||||
if (!wv) {
|
||||
g_free(normalized);
|
||||
return make_error("NO_TAB", "No active tab");
|
||||
}
|
||||
|
||||
webkit_web_view_load_uri(tab->webview, normalized);
|
||||
/* Dispatch the load to the GTK main thread. The agent loop (and
|
||||
* the libsoup MCP thread) are not the main thread, so calling
|
||||
* webkit_web_view_load_uri() directly would crash. g_idle_add()
|
||||
* schedules the call on the default main context, which is run by
|
||||
* the GTK main loop on the main thread. */
|
||||
load_uri_idle_t *ctx = g_new(load_uri_idle_t, 1);
|
||||
ctx->webview = wv;
|
||||
ctx->url = g_strdup(normalized);
|
||||
g_idle_add(load_uri_idle, ctx);
|
||||
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(data, "url", normalized);
|
||||
g_free(normalized);
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
/* Idle callback data for simple main-thread webview actions (back,
|
||||
* forward, reload). Like tool_open, these must run on the GTK main
|
||||
* thread because WebKitGTK is not thread-safe and the agent loop
|
||||
* runs on a background thread. */
|
||||
typedef enum {
|
||||
WEBVIEW_ACTION_BACK,
|
||||
WEBVIEW_ACTION_FORWARD,
|
||||
WEBVIEW_ACTION_RELOAD,
|
||||
WEBVIEW_ACTION_STOP
|
||||
} webview_action_t;
|
||||
|
||||
typedef struct {
|
||||
WebKitWebView *webview;
|
||||
webview_action_t action;
|
||||
} webview_action_idle_t;
|
||||
|
||||
static gboolean webview_action_idle(gpointer user_data) {
|
||||
webview_action_idle_t *ctx = (webview_action_idle_t *)user_data;
|
||||
if (ctx && ctx->webview) {
|
||||
switch (ctx->action) {
|
||||
case WEBVIEW_ACTION_BACK:
|
||||
if (webkit_web_view_can_go_back(ctx->webview))
|
||||
webkit_web_view_go_back(ctx->webview);
|
||||
break;
|
||||
case WEBVIEW_ACTION_FORWARD:
|
||||
if (webkit_web_view_can_go_forward(ctx->webview))
|
||||
webkit_web_view_go_forward(ctx->webview);
|
||||
break;
|
||||
case WEBVIEW_ACTION_RELOAD:
|
||||
webkit_web_view_reload_bypass_cache(ctx->webview);
|
||||
break;
|
||||
case WEBVIEW_ACTION_STOP:
|
||||
webkit_web_view_stop_loading(ctx->webview);
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_free(ctx);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static cJSON *tool_back(cJSON *params) {
|
||||
(void)params;
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
webkit_web_view_go_back(wv);
|
||||
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
|
||||
ctx->webview = wv;
|
||||
ctx->action = WEBVIEW_ACTION_BACK;
|
||||
g_idle_add(webview_action_idle, ctx);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
@@ -439,7 +559,10 @@ static cJSON *tool_forward(cJSON *params) {
|
||||
(void)params;
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
webkit_web_view_go_forward(wv);
|
||||
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
|
||||
ctx->webview = wv;
|
||||
ctx->action = WEBVIEW_ACTION_FORWARD;
|
||||
g_idle_add(webview_action_idle, ctx);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
@@ -447,7 +570,24 @@ static cJSON *tool_reload(cJSON *params) {
|
||||
(void)params;
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
webkit_web_view_reload_bypass_cache(wv);
|
||||
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
|
||||
ctx->webview = wv;
|
||||
ctx->action = WEBVIEW_ACTION_RELOAD;
|
||||
g_idle_add(webview_action_idle, ctx);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
/* Stop loading the active tab. Mirrors the toolbar stop button and the
|
||||
* hamburger menu's Stop action. Useful for testing the per-tab
|
||||
* reload/stop button state machine. */
|
||||
static cJSON *tool_stop(cJSON *params) {
|
||||
(void)params;
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
|
||||
ctx->webview = wv;
|
||||
ctx->action = WEBVIEW_ACTION_STOP;
|
||||
g_idle_add(webview_action_idle, ctx);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
@@ -1652,13 +1792,62 @@ static cJSON *tool_drag(cJSON *params) {
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
static cJSON *tool_close_all(cJSON *params) {
|
||||
(void)params;
|
||||
tab_manager_close_all();
|
||||
return make_success(NULL);
|
||||
/* ── Tab tools (main-thread dispatch) ────────────────────────────── *
|
||||
* All tab management functions (new, switch, close, close_all) create
|
||||
* or destroy GTK widgets, which MUST happen on the main thread. The
|
||||
* agent loop runs on a background thread, so we dispatch via
|
||||
* g_idle_add(). */
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
int index;
|
||||
} tab_idle_t;
|
||||
|
||||
static gboolean tab_new_idle(gpointer user_data) {
|
||||
tab_idle_t *ctx = (tab_idle_t *)user_data;
|
||||
if (ctx) {
|
||||
ctx->index = tab_manager_new_tab(ctx->url);
|
||||
g_free(ctx->url);
|
||||
g_free(ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── Tab tools ────────────────────────────────────────────────────── */
|
||||
static gboolean tab_switch_idle(gpointer user_data) {
|
||||
tab_idle_t *ctx = (tab_idle_t *)user_data;
|
||||
if (ctx) {
|
||||
tab_manager_switch_to(ctx->index);
|
||||
g_free(ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean tab_close_idle(gpointer user_data) {
|
||||
tab_idle_t *ctx = (tab_idle_t *)user_data;
|
||||
if (ctx) {
|
||||
tab_manager_close_tab(ctx->index);
|
||||
g_free(ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean close_all_idle(gpointer user_data) {
|
||||
(void)user_data;
|
||||
tab_manager_close_all();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean close_active_idle(gpointer user_data) {
|
||||
(void)user_data;
|
||||
tab_manager_close_active();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static cJSON *tool_close_all(cJSON *params) {
|
||||
(void)params;
|
||||
g_idle_add(close_all_idle, NULL);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
static cJSON *tool_tab_list(cJSON *params) {
|
||||
(void)params;
|
||||
@@ -1681,10 +1870,16 @@ static cJSON *tool_tab_list(cJSON *params) {
|
||||
|
||||
static cJSON *tool_tab_new(cJSON *params) {
|
||||
const char *url = get_string_param(params, "url");
|
||||
int index = tab_manager_new_tab(url);
|
||||
if (index < 0) return make_error("TAB_FAILED", "Failed to create tab (max tabs reached?)");
|
||||
/* Dispatch to main thread — tab_manager_new_tab() creates GTK
|
||||
* widgets which must happen on the main thread. */
|
||||
tab_idle_t *ctx = g_new(tab_idle_t, 1);
|
||||
ctx->url = url ? g_strdup(url) : NULL;
|
||||
ctx->index = -1;
|
||||
g_idle_add(tab_new_idle, ctx);
|
||||
/* We can't know the exact index since the creation is async, but
|
||||
* the tab will be created. Return a placeholder. */
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(data, "index", index);
|
||||
cJSON_AddNumberToObject(data, "index", tab_manager_count());
|
||||
return make_success(data);
|
||||
}
|
||||
|
||||
@@ -1692,7 +1887,9 @@ static cJSON *tool_tab_switch(cJSON *params) {
|
||||
int index = get_int_param(params, "index", -1);
|
||||
if (index < 0) return make_error("MISSING_PARAM", "Provide 'index'");
|
||||
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
|
||||
tab_manager_switch_to(index);
|
||||
tab_idle_t *ctx = g_new(tab_idle_t, 1);
|
||||
ctx->index = index;
|
||||
g_idle_add(tab_switch_idle, ctx);
|
||||
cJSON *data = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(data, "index", index);
|
||||
return make_success(data);
|
||||
@@ -1706,13 +1903,15 @@ static cJSON *tool_tab_close(cJSON *params) {
|
||||
if (index < 0) return make_error("NO_TAB", "No active tab");
|
||||
}
|
||||
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
|
||||
tab_manager_close_tab(index);
|
||||
tab_idle_t *ctx = g_new(tab_idle_t, 1);
|
||||
ctx->index = index;
|
||||
g_idle_add(tab_close_idle, ctx);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
static cJSON *tool_close(cJSON *params) {
|
||||
(void)params;
|
||||
tab_manager_close_active();
|
||||
g_idle_add(close_active_idle, NULL);
|
||||
return make_success(NULL);
|
||||
}
|
||||
|
||||
@@ -3924,6 +4123,7 @@ static tool_entry_t tool_table[] = {
|
||||
{"back", tool_back, TRUE},
|
||||
{"forward", tool_forward, TRUE},
|
||||
{"reload", tool_reload, TRUE},
|
||||
{"stop", tool_stop, TRUE},
|
||||
{"get_url", tool_get_url, TRUE},
|
||||
{"get_title", tool_get_title, TRUE},
|
||||
|
||||
@@ -4068,6 +4268,38 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
return make_error("MISSING_TOOL", "No 'tool' field in request");
|
||||
}
|
||||
|
||||
/* ── Filesystem & shell tools (work before login) ──────────── *
|
||||
* These are system-level tools that give the agent direct access
|
||||
* to the qube's filesystem and shell. They don't require a Nostr
|
||||
* login, so we dispatch them before the login check below. */
|
||||
if (agent_fs_is_tool(tool_name)) {
|
||||
return agent_fs_tools_dispatch(tool_name, params);
|
||||
}
|
||||
|
||||
/* ── Process / per-tab diagnostics (work before login) ────────── *
|
||||
* These read /proc and the in-memory probe store; they don't touch
|
||||
* Nostr or page content, so they're safe before login. */
|
||||
if (strcmp(tool_name, "processes.list") == 0) {
|
||||
cJSON *arr = process_info_get_processes_json();
|
||||
return make_success(arr);
|
||||
}
|
||||
if (strcmp(tool_name, "processes.tabs") == 0) {
|
||||
cJSON *arr = process_info_get_tabs_json();
|
||||
return make_success(arr);
|
||||
}
|
||||
if (strcmp(tool_name, "processes.tab_probe") == 0) {
|
||||
int idx = get_int_param(params, "tab_index", -1);
|
||||
if (idx < 0) {
|
||||
return make_error("MISSING_PARAM",
|
||||
"Provide 'tab_index' (from processes.tabs)");
|
||||
}
|
||||
cJSON *obj = process_info_get_tab_probe_json(idx);
|
||||
if (obj == NULL) {
|
||||
return make_error("BAD_INDEX", "Tab index out of range");
|
||||
}
|
||||
return make_success(obj);
|
||||
}
|
||||
|
||||
/* Check login requirement for known tools. */
|
||||
gboolean requires_login = TRUE;
|
||||
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
|
||||
|
||||
1112
src/bookmarks.c
1112
src/bookmarks.c
File diff suppressed because it is too large
Load Diff
133
src/bookmarks.h
133
src/bookmarks.h
@@ -1,9 +1,22 @@
|
||||
/*
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
|
||||
*
|
||||
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
|
||||
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
|
||||
* and stored in the event's content field as a JSON tag array.
|
||||
* 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.
|
||||
@@ -22,10 +35,15 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define BOOKMARKS_MAX_PER_DIR 500
|
||||
#define BOOKMARKS_DIR_NAME_MAX 128
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
#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;
|
||||
@@ -33,70 +51,101 @@ typedef struct {
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_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)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars)
|
||||
* 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);
|
||||
int bookmarks_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark list. Call at shutdown. */
|
||||
/* Free the in-memory bookmark tree. Call at shutdown. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* ── Read access ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Get the list of directories (read-only). Returns a pointer to the
|
||||
* internal array (valid until the next bookmarks operation). */
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
|
||||
/* Get 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);
|
||||
|
||||
/* Get a specific directory by name. Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
/* Find a node by path. Returns NULL if not found. */
|
||||
const bookmark_node_t *bookmarks_find(const char *path);
|
||||
|
||||
/* ── Write access (requires signer) ────────────────────────────────── */
|
||||
/* ── Write access (requires signer + privkey) ──────────────────────── */
|
||||
|
||||
/* 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.
|
||||
/* 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 *dir, const char *url, const char *title);
|
||||
int bookmarks_add(const char *path, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
/* Remove a bookmark by URL from a folder path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
int bookmarks_remove(const char *path, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
/* 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_dir, const char *url,
|
||||
const char *to_dir);
|
||||
int bookmarks_move(const char *from_path, const char *url,
|
||||
const char *to_path);
|
||||
|
||||
/* 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);
|
||||
/* 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);
|
||||
|
||||
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
|
||||
* to "General" before deletion. Publishes a kind 5 deletion event.
|
||||
/* 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_delete_dir(const char *name, int move_to_general);
|
||||
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) ────────────────────────────────── */
|
||||
|
||||
/* Load bookmarks for a directory from a decrypted kind 30003 event's
|
||||
* content JSON. Called by bookmarks_init and after relay fetch. */
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
|
||||
|
||||
/* Store a raw kind 30003 event in the database and decrypt its content
|
||||
* into the in-memory list. Called by the relay fetch after login. */
|
||||
* into the in-memory tree. Called by the relay fetch after login. */
|
||||
int bookmarks_store_and_load_event(const void *event_cjson);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
394
src/db.c
394
src/db.c
@@ -16,6 +16,7 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
|
||||
/* ── Global state ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -117,7 +118,25 @@ static const char *SCHEMA_SQL =
|
||||
"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 ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -432,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) {
|
||||
@@ -762,3 +865,292 @@ int db_session_clear(void) {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
72
src/db.h
72
src/db.h
@@ -95,6 +95,24 @@ 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 ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
@@ -196,6 +214,60 @@ int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
|
||||
*/
|
||||
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 */
|
||||
@@ -29,6 +29,9 @@ void history_add_titled(const char *url, const char *title) {
|
||||
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;
|
||||
|
||||
@@ -84,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;
|
||||
@@ -95,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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) {
|
||||
|
||||
@@ -87,6 +87,24 @@ int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
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);
|
||||
|
||||
536
src/main.c
536
src/main.c
@@ -36,6 +36,8 @@
|
||||
#include "key_store.h"
|
||||
#include "login_dialog.h"
|
||||
#include "nostr_bridge.h"
|
||||
#include "nostr_scheme.h"
|
||||
#include "tor_scheme.h"
|
||||
#include "nostr_inject.h"
|
||||
#include "history.h"
|
||||
#include "settings.h"
|
||||
@@ -50,6 +52,11 @@
|
||||
#include "profile.h"
|
||||
#include "relay_fetch.h"
|
||||
#include "bookmarks.h"
|
||||
#include "agent_conversations.h"
|
||||
#include "agent_skills.h"
|
||||
#include "net_services.h"
|
||||
#include "webkit_data.h"
|
||||
#include "web_context.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -61,21 +68,33 @@
|
||||
typedef struct {
|
||||
nostr_signer_t *signer; /* NULL for read-only mode */
|
||||
char pubkey_hex[65];
|
||||
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
|
||||
key_store_method_t method;
|
||||
gboolean readonly; /* TRUE if no signing available */
|
||||
} app_state_t;
|
||||
|
||||
static app_state_t g_state = {0};
|
||||
|
||||
/* Forward declaration — defined before main(). */
|
||||
/* Forward declarations — defined later in this file. */
|
||||
static int switch_to_user_db(const char *pubkey_hex);
|
||||
static int do_login(GtkWindow *parent);
|
||||
static WebKitWebContext *build_context_for_current_user(void);
|
||||
static char g_current_profile_db[512];
|
||||
static GtkWindow *g_window = NULL;
|
||||
static gboolean g_logged_in = FALSE;
|
||||
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
|
||||
|
||||
/* TRUE once main() has finished initial startup (tab_manager_init has
|
||||
* run). Used by app_set_signer() to distinguish a first-time login
|
||||
* (context built later by main()) from a runtime identity switch
|
||||
* (context was torn down by identity_teardown_web_state and must be
|
||||
* rebuilt here). */
|
||||
static gboolean g_post_startup = FALSE;
|
||||
|
||||
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
|
||||
|
||||
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex,
|
||||
key_store_method_t method, gboolean readonly) {
|
||||
g_state.signer = signer;
|
||||
g_state.method = method;
|
||||
@@ -84,11 +103,19 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
}
|
||||
if (privkey_hex && privkey_hex[0]) {
|
||||
strncpy(g_state.privkey_hex, privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
} else {
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
}
|
||||
g_logged_in = TRUE;
|
||||
|
||||
/* Update modules that hold a signer reference. */
|
||||
settings_sync_set_signer(signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
agent_conversations_set_signer(signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* If this is the first login (we're still on global.db), switch to
|
||||
* the per-user profile database. If we're already on a per-user db
|
||||
@@ -97,6 +124,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* If this is a RUNTIME identity switch (past startup) and the
|
||||
* previous context was torn down by identity_teardown_web_state(),
|
||||
* rebuild a fresh per-user context for the new identity now. On the
|
||||
* first login (before main() calls tab_manager_init), g_post_startup
|
||||
* is FALSE and web_context_get() is NULL, so main() builds the
|
||||
* context after login — we skip here to avoid a double-build. */
|
||||
if (g_post_startup && web_context_get() == NULL) {
|
||||
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user so they don't stare at
|
||||
* an empty window after the teardown closed all tabs. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to rebuild web context\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_clear_signer(void) {
|
||||
@@ -105,11 +149,13 @@ void app_clear_signer(void) {
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
agent_conversations_set_signer(NULL, NULL);
|
||||
}
|
||||
|
||||
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
|
||||
@@ -123,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; }
|
||||
* menu builder.
|
||||
*/
|
||||
|
||||
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
|
||||
* Called on logout and identity switch. Closes all tabs, destroys the
|
||||
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
|
||||
* with the previous user's sovereign://agents/chat session), and wipes
|
||||
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
|
||||
* service workers, favicons) from the shared WebKitWebsiteDataManager.
|
||||
*
|
||||
* Without this, the next user inherits the previous user's web session:
|
||||
* cookies identify you to web pages, localStorage holds per-site state,
|
||||
* and the live tabs keep the previous user's DOM in memory. See
|
||||
* plans/webkit-data-isolation.md for the full rationale.
|
||||
*
|
||||
* This must run on the GTK main thread. It pumps a nested main loop
|
||||
* briefly while webkit_website_data_manager_clear() completes.
|
||||
*/
|
||||
void identity_teardown_web_state(void) {
|
||||
/* 1. Close all tabs. This destroys the webviews and drops their
|
||||
* in-memory DOM/localStorage. Must happen before the context
|
||||
* teardown so no webviews hold dangling references to the old
|
||||
* context. Suppress the normal "last tab closed → quit the app"
|
||||
* behavior so the browser stays alive for the next user; the
|
||||
* caller opens a fresh tab after the switch. */
|
||||
tab_manager_set_suppress_quit_on_last_tab(TRUE);
|
||||
tab_manager_close_all();
|
||||
tab_manager_set_suppress_quit_on_last_tab(FALSE);
|
||||
|
||||
/* 2. Destroy the agent chat sidebar webviews in every window. The
|
||||
* sidebar webview is outside the notebook so tab_manager_close_all
|
||||
* does not touch it; it would otherwise keep a reference to the
|
||||
* old context (and the previous user's chat session) alive. */
|
||||
tab_manager_destroy_sidebar_webviews();
|
||||
|
||||
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
|
||||
* context and its per-user WebKitWebsiteDataManager. With Phase A
|
||||
* isolation, each user has their own data manager rooted at
|
||||
* profiles/<pubkey>/webkit/, so tearing down the context is what
|
||||
* actually isolates the users — the next build creates a fresh
|
||||
* data manager for the new user. The Phase 0 webkit_data_clear_all
|
||||
* wipe is no longer needed for isolation (the data manager is
|
||||
* per-user), but we keep it as a defense-in-depth safety net in
|
||||
* case any process-global WebKit state survived the context
|
||||
* teardown. */
|
||||
WebKitWebContext *ctx = web_context_get();
|
||||
if (ctx != NULL) {
|
||||
/* Defense-in-depth: clear the data manager before tearing down
|
||||
* the context. This catches any process-global caches that
|
||||
* outlive the context. */
|
||||
int rc = webkit_data_clear_all(ctx, FALSE);
|
||||
if (rc != 0) {
|
||||
g_printerr("[identity] webkit_data_clear_all returned %d "
|
||||
"(continuing with context teardown)\n", rc);
|
||||
}
|
||||
web_context_teardown();
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
@@ -130,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
login_result_t result;
|
||||
if (login_dialog_run(window, &result) == 0) {
|
||||
/* Tear down the previous user's web state BEFORE installing the
|
||||
* new signer, so the new user starts with a clean web session. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
}
|
||||
@@ -149,6 +255,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* Build a fresh per-user WebKitWebContext for the new identity
|
||||
* (with its own per-user data manager) and re-register the
|
||||
* sovereign://, nostr://, tor:// schemes on it. The old context
|
||||
* was torn down by identity_teardown_web_state() above. */
|
||||
if (build_context_for_current_user() == NULL) {
|
||||
g_printerr("[identity] Failed to build context for new user — "
|
||||
"browser will have no web views\n");
|
||||
}
|
||||
|
||||
/* Open a fresh tab for the new user so they don't stare at an
|
||||
* empty window after the teardown closed all the previous user's
|
||||
* tabs. Uses the new user's per-user new_tab_url setting. */
|
||||
const char *url = settings_get()->new_tab_url;
|
||||
tab_manager_new_tab(url);
|
||||
|
||||
g_print("[identity] switched: method=%d pubkey=%s\n",
|
||||
g_state.method, g_state.pubkey_hex);
|
||||
}
|
||||
@@ -168,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
|
||||
/* Tear down the current user's web state (closes all tabs, destroys
|
||||
* the sidebar webviews, wipes cookies/cache/localStorage/service
|
||||
* workers/favicons) BEFORE clearing the signer, so the next login
|
||||
* starts with a clean web session. See plans/webkit-data-isolation.md. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
agent_conversations_set_signer(NULL, NULL);
|
||||
|
||||
/* Reset the per-user db tracking so the next login re-opens the
|
||||
* new user's browser.db rather than assuming we're already on it. */
|
||||
g_current_profile_db[0] = '\0';
|
||||
|
||||
g_print("[identity] logged out\n");
|
||||
|
||||
/* Re-show the login dialog so the user (or agent) can log in as a
|
||||
* different identity. The dialog runs a nested main loop, so the
|
||||
* agent server stays live and an agent can log in via MCP while the
|
||||
* dialog is showing. On success, do_login() installs the new signer
|
||||
* and switch_to_user_db() opens the new user's profile. */
|
||||
if (window != NULL) {
|
||||
if (do_login(window) == 0) {
|
||||
/* Build a fresh per-user WebKitWebContext for the new
|
||||
* identity. The old context was torn down above. For
|
||||
* --no-login mode this builds an ephemeral context. */
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to build context after re-login\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
|
||||
@@ -185,10 +342,46 @@ void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
|
||||
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
void app_menu_fips_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
|
||||
void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data) {
|
||||
if (g_object_get_data(G_OBJECT(item), "network-toggle-sync") != NULL) return;
|
||||
|
||||
net_service_type_t type = (net_service_type_t)GPOINTER_TO_INT(data);
|
||||
if (type != NET_SERVICE_TOR && type != NET_SERVICE_FIPS) {
|
||||
g_printerr("[menu.network] Invalid network service type %d\n", (int)type);
|
||||
return;
|
||||
}
|
||||
|
||||
const char *name = type == NET_SERVICE_TOR ? "Tor" : "FIPS";
|
||||
gboolean enabled = gtk_check_menu_item_get_active(item);
|
||||
browser_settings_t *settings = settings_get_mutable();
|
||||
gboolean *setting = type == NET_SERVICE_TOR
|
||||
? &settings->tor_enabled : &settings->fips_enabled;
|
||||
|
||||
*setting = enabled;
|
||||
settings_save_user();
|
||||
|
||||
int rc = enabled ? net_service_enable(type) : net_service_disable(type);
|
||||
if (enabled && rc != 0) {
|
||||
const net_service_t *status = net_service_get_status(type);
|
||||
g_printerr("[menu.network] Failed to enable %s service: %s; reverting preference\n",
|
||||
name, status && status->error_msg
|
||||
? status->error_msg : "synchronous startup failure");
|
||||
*setting = FALSE;
|
||||
settings_save_user();
|
||||
g_object_set_data(G_OBJECT(item), "network-toggle-sync",
|
||||
GINT_TO_POINTER(1));
|
||||
gtk_check_menu_item_set_active(item, FALSE);
|
||||
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rc != 0) {
|
||||
g_printerr("[menu.network] Failed to disable %s service (preference remains disabled)\n",
|
||||
name);
|
||||
} else {
|
||||
g_print("[menu.network] %s service %s; per-user preference saved\n",
|
||||
name, enabled ? "enabled" : "disabled");
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
|
||||
@@ -228,29 +421,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
|
||||
* sovereign:// URI scheme bridge in nostr_bridge.c.
|
||||
*/
|
||||
|
||||
/* Hamburger-menu items for sovereign:// internal pages always open in
|
||||
* a new tab so the user's current page is preserved. */
|
||||
void on_menu_settings(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
|
||||
} else {
|
||||
/* No active tab — open a new one pointed at the settings page. */
|
||||
tab_manager_new_tab("sovereign://settings");
|
||||
}
|
||||
tab_manager_new_tab("sovereign://settings");
|
||||
}
|
||||
|
||||
void on_menu_profile(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
|
||||
} else {
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
|
||||
* internal page in a new tab. It renders the agent provider
|
||||
* configuration UI and a link to open the agent chat. */
|
||||
void on_menu_agent(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://agents");
|
||||
}
|
||||
|
||||
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
|
||||
* internal page in a new tab. It renders the FIPS mesh network
|
||||
* status + management UI. */
|
||||
void on_menu_fips(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://fips");
|
||||
}
|
||||
|
||||
/* The hamburger-menu "Processes…" item opens the sovereign://processes
|
||||
* internal page in a new tab. It renders the process list + per-tab
|
||||
* performance diagnostics UI. */
|
||||
void on_menu_processes(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://processes");
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- *
|
||||
@@ -260,12 +469,44 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
|
||||
* sovereign://settings page (key-capture UI) and persisted to SQLite.
|
||||
*/
|
||||
|
||||
/* Made non-static so tab_manager.c can connect it to each webview. */
|
||||
/* Made non-static so tab_manager.c can connect it to each webview.
|
||||
*
|
||||
* This handler is connected to two places:
|
||||
* 1. Each webview's "key-press-event" (in tab_manager.c tab_create())
|
||||
* 2. The main window's "key-press-event" (in main.c setup)
|
||||
*
|
||||
* For the webview connection, we bypass shortcut interception when the
|
||||
* active page is a sovereign:// internal page — this lets the page's JS
|
||||
* capture arbitrary key combos (e.g. the settings page's shortcut capture
|
||||
* UI needs to see Ctrl+key events that would otherwise be intercepted).
|
||||
*
|
||||
* For the window connection, we always process shortcuts so that
|
||||
* browser-level shortcuts (Ctrl+T, Ctrl+N, etc.) work even on
|
||||
* sovereign:// pages. The window handler is a fallback that fires after
|
||||
* the webview handler (event propagation: child → parent → window). */
|
||||
gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
gpointer data) {
|
||||
(void)widget;
|
||||
(void)data;
|
||||
|
||||
/* Check if this handler is connected to a webview (not the window).
|
||||
* WEBKIT_IS_WEB_VIEW is true for webview connections, false for the
|
||||
* window connection. */
|
||||
gboolean from_webview = WEBKIT_IS_WEB_VIEW(widget);
|
||||
|
||||
/* When the active tab is showing a sovereign:// internal page (e.g.
|
||||
* the settings page's keyboard-shortcut capture), bypass shortcut
|
||||
* interception on the webview connection only — let the event pass
|
||||
* through to the web page's JS so it can capture arbitrary key combos.
|
||||
* The window-level connection still processes shortcuts so Ctrl+T,
|
||||
* Ctrl+N, etc. work on internal pages. */
|
||||
if (from_webview) {
|
||||
tab_info_t *active = tab_manager_get_active();
|
||||
if (active && active->current_url[0] != '\0' &&
|
||||
strncmp(active->current_url, "sovereign://", 12) == 0) {
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
int action = shortcuts_lookup(event);
|
||||
if (action < 0) return FALSE;
|
||||
|
||||
@@ -275,6 +516,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
tab_manager_new_tab(NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_NEW_WINDOW:
|
||||
tab_manager_new_window_blank();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_CLOSE_TAB:
|
||||
tab_manager_close_active();
|
||||
return TRUE;
|
||||
@@ -342,6 +587,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
on_menu_settings(NULL, NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_OPEN_PROCESSES:
|
||||
on_menu_processes(NULL, NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_NEW_IDENTITY:
|
||||
app_menu_switch_identity_proxy(NULL, g_window);
|
||||
return TRUE;
|
||||
@@ -361,6 +610,22 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
tab_manager_toggle_inspector();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_TOGGLE_SIDEBAR:
|
||||
tab_manager_toggle_sidebar();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_ZOOM_IN:
|
||||
tab_manager_zoom_in();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_ZOOM_OUT:
|
||||
tab_manager_zoom_out();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_ZOOM_RESET:
|
||||
tab_manager_zoom_reset();
|
||||
return TRUE;
|
||||
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
@@ -378,14 +643,102 @@ static void agent_login_callback(void) {
|
||||
g_print("[login] Agent login detected.\n");
|
||||
}
|
||||
|
||||
/* ---- Window destroy ------------------------------------------------- */
|
||||
/* ---- Window close / destroy ----------------------------------------- *
|
||||
* The main window uses a delete-event handler to intercept the window
|
||||
* manager's close request BEFORE the window is destroyed. This lets us
|
||||
* close the main window's tabs cleanly (updating g_tab_count) and keep
|
||||
* the app running if auxiliary windows still have tabs. The app only
|
||||
* quits when the last window is closed (no tabs remain anywhere).
|
||||
*
|
||||
* delete-event: runs first. Closes all main-window tabs. If aux windows
|
||||
* still have tabs, returns TRUE to suppress destroy and hides the
|
||||
* main window. If no tabs remain, returns FALSE to let destroy
|
||||
* proceed → on_window_destroy → gtk_main_quit().
|
||||
* destroy: runs only when the app is truly shutting down. Performs
|
||||
* session save, net_services_shutdown, agent_server_stop, etc.
|
||||
*/
|
||||
|
||||
/* Guard flag: set while on_window_delete_event is closing the main
|
||||
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
|
||||
* calls gtk_window_close(g_window) after the last tab is closed, which
|
||||
* would re-emit delete-event. */
|
||||
static gboolean g_main_window_closing = FALSE;
|
||||
|
||||
static gboolean on_window_delete_event(GtkWidget *widget,
|
||||
GdkEvent *event,
|
||||
gpointer data) {
|
||||
(void)event;
|
||||
(void)data;
|
||||
|
||||
/* If we're already in the process of closing (re-entrant call from
|
||||
* tab_manager_close_tab → gtk_window_close), let the destroy
|
||||
* proceed. */
|
||||
if (g_main_window_closing) {
|
||||
return FALSE;
|
||||
}
|
||||
g_main_window_closing = TRUE;
|
||||
|
||||
/* Close all tabs that belong to the main window's notebook. We
|
||||
* identify main-window tabs by checking gtk_notebook_page_num on
|
||||
* the main notebook. tab_manager_close_tab handles removing from
|
||||
* the notebook and the g_tabs array. Re-scan each iteration because
|
||||
* closing shifts indices. */
|
||||
for (;;) {
|
||||
gboolean found = FALSE;
|
||||
GtkWidget *main_nb = tab_manager_get_main_notebook();
|
||||
if (main_nb == NULL) break;
|
||||
/* Close from the highest index down so removal doesn't shift
|
||||
* unprocessed indices. Find the highest-index tab in the main
|
||||
* notebook and close it. */
|
||||
for (int i = tab_manager_count() - 1; i >= 0; i--) {
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
if (tab == NULL || tab->page == NULL) continue;
|
||||
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
|
||||
tab->page) >= 0) {
|
||||
tab_manager_close_tab(i);
|
||||
found = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
|
||||
/* If auxiliary windows still have tabs, keep the app running.
|
||||
* Suppress the default destroy by returning TRUE, and hide the
|
||||
* main window. The aux windows continue independently. */
|
||||
if (tab_manager_count() > 0) {
|
||||
g_print("[windows] Main window closed but %d tab(s) remain in "
|
||||
"other window(s) — keeping app alive.\n",
|
||||
tab_manager_count());
|
||||
gtk_widget_hide(widget);
|
||||
g_main_window_closing = FALSE; /* allow re-opening later */
|
||||
return TRUE; /* suppress destroy */
|
||||
}
|
||||
|
||||
/* No tabs left anywhere — let the destroy proceed, which triggers
|
||||
* on_window_destroy and quits the app. Keep the guard set so any
|
||||
* re-entrant delete-event from the destroy path doesn't re-enter. */
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
(void)widget;
|
||||
(void)data;
|
||||
|
||||
/* Save the session before cleanup. */
|
||||
session_save();
|
||||
const browser_settings_t *s = settings_get();
|
||||
|
||||
if (s->restore_session) {
|
||||
/* Save the session for next launch. */
|
||||
session_save();
|
||||
} else {
|
||||
/* Privacy mode: clear session and history on shutdown. */
|
||||
db_session_clear();
|
||||
history_clear();
|
||||
g_print("[shutdown] Cleared session and history (restore_session=off)\n");
|
||||
}
|
||||
|
||||
/* Stop browser-managed Tor/FIPS before tearing down GTK/WebKit. */
|
||||
net_services_shutdown();
|
||||
|
||||
/* Stop the agent server. */
|
||||
agent_server_stop();
|
||||
@@ -434,6 +787,10 @@ static int do_login(GtkWindow *parent) {
|
||||
g_state.method = result.method;
|
||||
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
|
||||
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
|
||||
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
|
||||
result.method == KEY_STORE_METHOD_NONE);
|
||||
g_logged_in = TRUE;
|
||||
@@ -462,9 +819,9 @@ static int do_login(GtkWindow *parent) {
|
||||
|
||||
/* Track the currently-open per-user db path so we can skip re-opening
|
||||
* the same database (e.g. when app_set_signer() is called during the
|
||||
* login dialog and then main() calls switch_to_user_db() again). */
|
||||
static char g_current_profile_db[512] = "";
|
||||
|
||||
* login dialog and then main() calls switch_to_user_db() again).
|
||||
* The actual definition is near the top of this file (forward-declared
|
||||
* before the menu proxies that reset it on logout). */
|
||||
static int switch_to_user_db(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[profile] No pubkey, staying on global.db\n");
|
||||
@@ -519,6 +876,52 @@ static int switch_to_user_db(const char *pubkey_hex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Per-user WebKit context build helper --------------------------- *
|
||||
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
|
||||
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
|
||||
* and updates tab_manager so new tabs use the new context. Called from:
|
||||
* - main() after login, before creating tabs
|
||||
* - app_menu_switch_identity_proxy() after tearing down the old context
|
||||
* - app_menu_logout_proxy() after re-login
|
||||
*
|
||||
* The caller must have torn down the previous context (via
|
||||
* web_context_teardown()) and closed all tabs/sidebar webviews first.
|
||||
* Returns the new context, or NULL on failure (the caller may fall back
|
||||
* to a blank window or exit).
|
||||
*/
|
||||
static WebKitWebContext *build_context_for_current_user(void) {
|
||||
WebKitWebContext *web_ctx;
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
|
||||
} else {
|
||||
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
|
||||
* persistence. See plans/webkit-data-isolation.md. */
|
||||
web_ctx = web_context_build_ephemeral();
|
||||
}
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Failed to build WebKit context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Register the sovereign:// URI scheme for the window.nostr bridge.
|
||||
* nostr_bridge_set_signer() was already called by the login path,
|
||||
* so the bridge will use the current signer. */
|
||||
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
|
||||
/* Register nostr:// and tor:// entity-page schemes on this context.
|
||||
* These are per-context registrations, so they must be re-registered
|
||||
* on every fresh context. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
tor_scheme_register(web_ctx);
|
||||
|
||||
/* Point tab_manager at the new context so subsequent new tabs (and
|
||||
* the sidebar webview) are created from it. */
|
||||
tab_manager_set_context(web_ctx);
|
||||
|
||||
return web_ctx;
|
||||
}
|
||||
|
||||
/* ---- Main ----------------------------------------------------------- */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@@ -622,6 +1025,8 @@ int main(int argc, char **argv) {
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
|
||||
g_signal_connect(window, "delete-event",
|
||||
G_CALLBACK(on_window_delete_event), NULL);
|
||||
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
|
||||
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
|
||||
g_window = GTK_WINDOW(window);
|
||||
@@ -733,46 +1138,52 @@ int main(int argc, char **argv) {
|
||||
settings_sync_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Initialize the conversation persistence module (kind 30078
|
||||
* conversations). In no-login/read-only mode, the signer is NULL
|
||||
* so conversations are not encrypted/synced. */
|
||||
agent_conversations_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Initialize the skills module (kind 31123 public skill events).
|
||||
* In no-login/read-only mode, the signer is NULL so skills cannot
|
||||
* be published/deleted (but can still be fetched and selected). */
|
||||
agent_skills_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Set the user's avatar on the tab bar from their kind 0 profile.
|
||||
* If the profile hasn't been fetched yet (relay fetch is still
|
||||
* running), this shows the default icon. The avatar will be updated
|
||||
* when the relay fetch completes and the kind 0 event is stored. */
|
||||
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Use the default WebKitWebContext — it comes with proper networking
|
||||
* (cookies, cache, soup session) that a freshly created context lacks.
|
||||
* All tabs will create webviews from this shared context. */
|
||||
WebKitWebContext *web_ctx = webkit_web_context_get_default();
|
||||
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
|
||||
* Build a fresh WebKitWebContext with a per-user
|
||||
* WebKitWebsiteDataManager rooted at
|
||||
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
|
||||
* data manager for --no-login mode). This gives true per-identity
|
||||
* isolation of cookies, cache, localStorage, IndexedDB, service
|
||||
* workers, and favicons. The helper also registers the sovereign://,
|
||||
* nostr://, tor:// URI schemes on the new context and points
|
||||
* tab_manager at it. */
|
||||
WebKitWebContext *web_ctx = build_context_for_current_user();
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Cannot continue without a WebKit context.\n");
|
||||
agent_server_stop();
|
||||
nostr_cleanup();
|
||||
cli_args_free(&cli);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
/* ── Security strip: disable web security restrictions ─────── */
|
||||
/* Fetch the security manager for the new context — used below to
|
||||
* wire the sovereign://security page to the first tab's settings. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(web_ctx);
|
||||
/* Register sovereign:// as secure only. Do NOT register it as "local"
|
||||
* (WebKit blocks fetch from https to local origins) and do NOT register
|
||||
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
|
||||
* which we can't set with the basic finish API). Without these, WebKit
|
||||
* treats sovereign:// as a simple secure scheme with no CORS checks. */
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
WebKitWebsiteDataManager *data_mgr =
|
||||
webkit_web_context_get_website_data_manager(web_ctx);
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* Enable the favicon database so WebKitGTK automatically fetches and
|
||||
* caches favicons for visited pages. Without this, the
|
||||
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
|
||||
* always returns NULL. Passing NULL for the directory uses WebKit's
|
||||
* default cache location. */
|
||||
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
|
||||
g_print("[main] Favicon database enabled\n");
|
||||
|
||||
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
|
||||
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
/* Initialize browser-managed/attached network services after the
|
||||
* per-user context has been built. net_services_refresh_proxy()
|
||||
* now uses web_context_get() so proxy settings apply to the
|
||||
* per-user context. */
|
||||
net_services_init();
|
||||
|
||||
/* Vertical box: the tab manager's notebook fills the window. */
|
||||
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
@@ -781,6 +1192,11 @@ int main(int argc, char **argv) {
|
||||
/* Initialize the tab manager. */
|
||||
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
|
||||
|
||||
/* Mark startup complete. From now on, app_set_signer() (called by
|
||||
* agent login / switch_identity) will rebuild the per-user web
|
||||
* context if it was torn down, since main() won't build it again. */
|
||||
g_post_startup = TRUE;
|
||||
|
||||
/* Decide whether to restore the previous session or open CLI URLs.
|
||||
*
|
||||
* Precedence:
|
||||
@@ -821,6 +1237,10 @@ int main(int argc, char **argv) {
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
/* Re-hide the sidebar container that show_all revealed. The sidebar
|
||||
* is per-window (packed in the window-level GtkPaned) and should
|
||||
* only appear when the user toggles it. */
|
||||
tab_manager_hide_sidebar_after_show_all();
|
||||
gtk_main();
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
|
||||
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 */
|
||||
3067
src/nostr_bridge.c
3067
src/nostr_bridge.c
File diff suppressed because it is too large
Load Diff
@@ -128,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);
|
||||
@@ -148,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 */
|
||||
@@ -106,6 +106,46 @@ int profile_ensure_dir(const char *pubkey_hex) {
|
||||
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';
|
||||
|
||||
@@ -47,6 +47,21 @@ void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
*/
|
||||
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).
|
||||
|
||||
@@ -32,6 +32,20 @@
|
||||
|
||||
/* 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 ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -47,7 +61,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_print("[relay] Fetching kind 0/3/10002/30003/30078 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, 30003, 30078]} */
|
||||
@@ -67,7 +81,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
/* 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,
|
||||
@@ -87,7 +101,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
/* Store each event in the database. Kind 30003 (bookmark sets) are
|
||||
* also decrypted and loaded into the in-memory bookmarks list.
|
||||
* Kind 30078 (NIP-78 app data) is stored and merged into local
|
||||
* settings if it's our sovereign_browser settings event. */
|
||||
* 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;
|
||||
@@ -104,7 +119,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
} else if (kind == 30078) {
|
||||
/* Store in SQLite first, then try to merge into local settings.
|
||||
* settings_sync_merge_from_nostr checks the d-tag and only
|
||||
* merges if it's our "sovereign_browser" event. */
|
||||
* 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++;
|
||||
}
|
||||
@@ -132,6 +148,58 @@ int relay_fetch_bootstrap(const char *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;
|
||||
@@ -178,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
|
||||
|
||||
194
src/settings.c
194
src/settings.c
@@ -54,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;
|
||||
@@ -64,6 +64,7 @@ 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;
|
||||
@@ -71,11 +72,60 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
|
||||
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
|
||||
SETTINGS_SEARCH_ENGINE_DEFAULT);
|
||||
s->theme_dark = TRUE; /* default: dark mode */
|
||||
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;
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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));
|
||||
|
||||
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");
|
||||
|
||||
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 ──────────────────────────────────────────────── */
|
||||
@@ -154,6 +204,9 @@ void settings_load_global(void) {
|
||||
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);
|
||||
@@ -235,6 +288,110 @@ void settings_load_user(void) {
|
||||
|
||||
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) {
|
||||
@@ -265,6 +422,9 @@ void settings_save_global(void) {
|
||||
db_kv_set_to_file(gpath, "agent_server_enabled",
|
||||
g_settings.agent_server_enabled ? "true" : "false");
|
||||
|
||||
db_kv_set_to_file(gpath, "json_viewer_enabled",
|
||||
g_settings.json_viewer_enabled ? "true" : "false");
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
|
||||
db_kv_set_to_file(gpath, "agent_server_port", buf);
|
||||
|
||||
@@ -309,6 +469,36 @@ void settings_save_user(void) {
|
||||
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) {
|
||||
|
||||
@@ -28,6 +28,38 @@ extern "C" {
|
||||
"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 */
|
||||
@@ -39,16 +71,56 @@ 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;
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,15 +2,45 @@
|
||||
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs whitelisted, device-independent settings + keyboard shortcut
|
||||
* bindings across all devices the user logs in on. Uses a single kind
|
||||
* 30078 addressable event with d-tag "sovereign_browser". The content is
|
||||
* NIP-44 encrypted (self-to-self) JSON.
|
||||
* 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"
|
||||
@@ -38,11 +68,12 @@ static guint g_publish_timeout_id = 0;
|
||||
/* db_kv key for the last-synced timestamp. */
|
||||
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
|
||||
|
||||
/* ── Whitelist of syncable settings ─────────────────────────────────── */
|
||||
/* ── Whitelist of syncable browser settings ─────────────────────────── */
|
||||
|
||||
/* Keys from the settings struct that should be synced. Device-specific
|
||||
* settings (agent port, allowed origins, session restore, security
|
||||
* toggles) are intentionally excluded. */
|
||||
/* 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",
|
||||
@@ -111,33 +142,197 @@ static char *decrypt_content(const char *ciphertext) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/* ── Serialization ──────────────────────────────────────────────────── */
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Serialize all syncable settings + shortcuts to a JSON object:
|
||||
* {"settings":{...}, "shortcuts":{...}}
|
||||
/* ── 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 *serialize_payload(void) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
/* Settings whitelist. */
|
||||
cJSON *settings_obj = cJSON_CreateObject();
|
||||
const browser_settings_t *s = settings_get();
|
||||
(void)s; /* read via db_kv_get to get string values uniformly */
|
||||
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(settings_obj, key, val);
|
||||
cJSON_AddStringToObject(sb, key, val);
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(root, "settings", settings_obj);
|
||||
|
||||
/* 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(root, "shortcuts", shortcuts_obj);
|
||||
cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj);
|
||||
|
||||
return root;
|
||||
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) ────────────────────────────────────────────── */
|
||||
@@ -146,8 +341,13 @@ static cJSON *serialize_payload(void) {
|
||||
static void do_publish(void) {
|
||||
if (!g_have_signer) return;
|
||||
|
||||
/* Serialize. */
|
||||
cJSON *payload = serialize_payload();
|
||||
/* 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) {
|
||||
@@ -160,7 +360,7 @@ static void do_publish(void) {
|
||||
free(json);
|
||||
if (ciphertext == NULL) return;
|
||||
|
||||
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
|
||||
/* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */
|
||||
cJSON *tags = cJSON_CreateArray();
|
||||
|
||||
cJSON *d_tag = cJSON_CreateArray();
|
||||
@@ -203,8 +403,9 @@ static void do_publish(void) {
|
||||
relay_urls, relay_count, event, &success_count,
|
||||
15, NULL, NULL, 0, NULL);
|
||||
if (results) free(results);
|
||||
g_print("[settings_sync] Published kind %d to %d/%d relays\n",
|
||||
SETTINGS_SYNC_KIND, success_count, relay_count);
|
||||
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");
|
||||
}
|
||||
@@ -221,6 +422,267 @@ static gboolean publish_timeout_cb(gpointer data) {
|
||||
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) {
|
||||
@@ -258,37 +720,29 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Verify the d-tag is "sovereign_browser". */
|
||||
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
if (!cJSON_IsArray(tags)) return -1;
|
||||
|
||||
int is_ours = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
|
||||
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
|
||||
cJSON_IsString(t1) &&
|
||||
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
|
||||
is_ours = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!is_ours) return -1;
|
||||
/* 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. */
|
||||
/* 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) not newer than local (%ld), "
|
||||
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;
|
||||
}
|
||||
@@ -309,28 +763,51 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Merge settings. */
|
||||
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
|
||||
if (cJSON_IsObject(settings_obj)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, settings_obj) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
/* Only merge whitelisted keys. */
|
||||
for (int i = 0; i < g_sync_setting_count; i++) {
|
||||
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
|
||||
db_kv_set(item->string, item->valuestring);
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
/* Reload settings from db_kv into the in-memory singleton. */
|
||||
settings_load();
|
||||
}
|
||||
|
||||
/* Merge shortcuts. */
|
||||
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
|
||||
if (cJSON_IsObject(shortcuts_obj)) {
|
||||
shortcuts_merge_from_json(shortcuts_obj);
|
||||
/* 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. */
|
||||
@@ -338,7 +815,8 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
|
||||
db_kv_set(SYNC_TS_KEY, ts_buf);
|
||||
|
||||
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
|
||||
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);
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
|
||||
*
|
||||
* Syncs user-configurable, device-independent settings across all devices
|
||||
* the user logs in on. Uses a single kind 30078 addressable event with
|
||||
* d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self)
|
||||
* JSON containing whitelisted settings + all keyboard shortcut bindings.
|
||||
* 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/keyboard-shortcuts.md for the full design.
|
||||
* See plans/cross-project-agent-sync.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef SETTINGS_SYNC_H
|
||||
@@ -24,8 +29,12 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The d-tag identifier used for our kind 30078 event. */
|
||||
#define SETTINGS_SYNC_D_TAG "sovereign_browser"
|
||||
/* 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
|
||||
@@ -59,7 +68,8 @@ void settings_sync_publish(void);
|
||||
* overwrites local db_kv values and reloads in-memory bindings.
|
||||
*
|
||||
* event — a cJSON object representing a kind 30078 event with
|
||||
* d-tag "sovereign_browser"
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"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"
|
||||
@@ -74,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"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",
|
||||
@@ -87,6 +96,22 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -28,6 +28,7 @@ extern "C" {
|
||||
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_NEW_WINDOW,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
@@ -40,9 +41,14 @@ typedef enum {
|
||||
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;
|
||||
|
||||
|
||||
1937
src/tab_manager.c
1937
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).
|
||||
*/
|
||||
@@ -160,6 +207,54 @@ void tab_manager_set_avatar(const char *pubkey_hex);
|
||||
*/
|
||||
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);
|
||||
}
|
||||
20
src/tor_scheme.h
Normal file
20
src/tor_scheme.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* tor_scheme.h — WebKitGTK tor:// URI scheme handler
|
||||
*/
|
||||
|
||||
#ifndef TOR_SCHEME_H
|
||||
#define TOR_SCHEME_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void tor_scheme_register(WebKitWebContext *ctx);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* TOR_SCHEME_H */
|
||||
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.22"
|
||||
#define SB_VERSION "v0.0.56"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 22
|
||||
#define SB_VERSION_PATCH 56
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
156
src/web_context.c
Normal file
156
src/web_context.c
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* web_context.c — per-user WebKitWebContext construction/teardown
|
||||
*
|
||||
* See web_context.h for the rationale. Phase A of
|
||||
* plans/webkit-data-isolation.md.
|
||||
*/
|
||||
|
||||
#include "web_context.h"
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* The current per-user context. NULL before the first build and after
|
||||
* teardown. Replaces webkit_web_context_get_default() for code that
|
||||
* wants the per-user context. */
|
||||
static WebKitWebContext *g_ctx = NULL;
|
||||
|
||||
/* ── Security / TLS / favicon configuration ────────────────────────── *
|
||||
* Applied to every context we build (per-user and ephemeral). Mirrors
|
||||
* the configuration that main.c used to apply to the default context.
|
||||
*/
|
||||
static void configure_context(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
|
||||
/* Security strip: register sovereign://, tor://, file:// as secure
|
||||
* (no CORS enforcement), and file:// as local. Same as the original
|
||||
* main.c configuration. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
WebKitWebsiteDataManager *dm =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* Enable the favicon database so notify::favicon fires. Use the
|
||||
* data manager's base data directory (per-user) so favicons are
|
||||
* isolated too. Passing NULL would use a shared default location. */
|
||||
char fav_dir[600];
|
||||
WebKitWebsiteDataManager *data_mgr =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr);
|
||||
if (base != NULL) {
|
||||
g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base);
|
||||
} else {
|
||||
/* Ephemeral data manager has no base dir; use a temp favicons
|
||||
* dir so the favicon DB still works in no-login mode. */
|
||||
g_snprintf(fav_dir, sizeof(fav_dir),
|
||||
"%s/sovereign_browser-favicons-ephemeral",
|
||||
g_get_tmp_dir());
|
||||
}
|
||||
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[web-context] No pubkey — cannot build per-user context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Ensure the per-user webkit data directory exists. */
|
||||
if (profile_ensure_webkit_dir(pubkey_hex) != 0) {
|
||||
g_printerr("[web-context] Failed to create webkit dir for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char webkit_dir[600];
|
||||
profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir));
|
||||
if (webkit_dir[0] == '\0') {
|
||||
g_printerr("[web-context] Failed to get webkit dir path for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the per-user data manager. base-data-directory covers
|
||||
* local-storage, indexed-db, websql, service-worker-registrations,
|
||||
* offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory
|
||||
* covers disk-cache. WebKit derives subdirs under these. */
|
||||
char data_dir[600];
|
||||
char cache_dir[600];
|
||||
g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir);
|
||||
g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir);
|
||||
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new(
|
||||
"base-data-directory", data_dir,
|
||||
"base-cache-directory", cache_dir,
|
||||
NULL);
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build a fresh context bound to this data manager. */
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The context owns the data manager now; drop our ref. */
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n",
|
||||
pubkey_hex, data_dir, cache_dir);
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_ephemeral(void) {
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral();
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built ephemeral context (no on-disk persistence)\n");
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
void web_context_teardown(void) {
|
||||
if (g_ctx == NULL) return;
|
||||
|
||||
/* Unref the context. The context releases its reference to the data
|
||||
* manager. Any remaining webviews referencing this context would
|
||||
* keep it alive — the caller must have closed them first. */
|
||||
WebKitWebContext *old = g_ctx;
|
||||
g_ctx = NULL;
|
||||
g_object_unref(old);
|
||||
|
||||
g_print("[web-context] Torn down previous context\n");
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_get(void) {
|
||||
return g_ctx;
|
||||
}
|
||||
88
src/web_context.h
Normal file
88
src/web_context.h
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* web_context.h — per-user WebKitWebContext construction/teardown
|
||||
*
|
||||
* Phase A of plans/webkit-data-isolation.md. Each Nostr identity gets
|
||||
* its own WebKitWebContext backed by a per-user WebKitWebsiteDataManager
|
||||
* rooted at ~/.sovereign_browser/profiles/<pubkey>/webkit/. This gives
|
||||
* true isolation: cookies, cache, localStorage, IndexedDB, service
|
||||
* workers, and favicons persist per-user and never cross-contaminate.
|
||||
*
|
||||
* WebKitGTK binds a WebKitWebsiteDataManager to a WebKitWebContext for
|
||||
* the context's lifetime, so per-user isolation requires building a
|
||||
* fresh context per login and tearing it down on logout/switch.
|
||||
*
|
||||
* The current context pointer is held in this module so the rest of the
|
||||
* codebase can fetch it via web_context_get() instead of
|
||||
* webkit_web_context_get_default().
|
||||
*/
|
||||
|
||||
#ifndef WEB_CONTEXT_H
|
||||
#define WEB_CONTEXT_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Build a fresh WebKitWebContext with a per-user WebKitWebsiteDataManager
|
||||
* rooted at ~/.sovereign_browser/profiles/<pubkey_hex>/webkit/.
|
||||
*
|
||||
* pubkey_hex — 64-char hex pubkey. Must be non-NULL/non-empty.
|
||||
*
|
||||
* Configures:
|
||||
* - per-user base-data-directory + base-cache-directory (WebKit derives
|
||||
* local-storage, indexed-db, websql, service-worker, offline-app-cache,
|
||||
* hsts, itp, device-id-hash-salt under these)
|
||||
* - favicon database enabled (per-user)
|
||||
* - TLS errors ignored (FIPS uses Noise IK, not TLS CAs)
|
||||
* - security manager: sovereign://, tor://, file:// registered as
|
||||
* secure; file:// also registered as local
|
||||
*
|
||||
* Stores the new context as the current context (returned by
|
||||
* web_context_get()). The caller must call web_context_teardown() on the
|
||||
* previous context first if one exists.
|
||||
*
|
||||
* Returns the new WebKitWebContext (owned by this module), or NULL on
|
||||
* failure.
|
||||
*/
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Build a fresh WebKitWebContext with an EPHEMERAL data manager (no
|
||||
* on-disk persistence). Used for --no-login / read-only mode where
|
||||
* there is no pubkey to key a per-user data directory on, and for the
|
||||
* logged-out state. Same security/TLS/favicon config as
|
||||
* web_context_build_for_pubkey.
|
||||
*
|
||||
* Stores the new context as the current context.
|
||||
*
|
||||
* Returns the new WebKitWebContext, or NULL on failure.
|
||||
*/
|
||||
WebKitWebContext *web_context_build_ephemeral(void);
|
||||
|
||||
/*
|
||||
* Tear down the current per-user WebKitWebContext: unref the context
|
||||
* and its data manager. After this, web_context_get() returns NULL
|
||||
* until the next build call.
|
||||
*
|
||||
* The caller MUST have already closed all webviews that reference the
|
||||
* old context (tabs + sidebar webviews) before calling this, otherwise
|
||||
* the webviews will hold dangling references. identity_teardown_web_state()
|
||||
* in main.c does this.
|
||||
*/
|
||||
void web_context_teardown(void);
|
||||
|
||||
/*
|
||||
* Return the current WebKitWebContext, or NULL if none has been built
|
||||
* (or the last one was torn down). Replaces webkit_web_context_get_default()
|
||||
* for code that wants the per-user context.
|
||||
*/
|
||||
WebKitWebContext *web_context_get(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* WEB_CONTEXT_H */
|
||||
113
src/webkit_data.c
Normal file
113
src/webkit_data.c
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* webkit_data.c — helpers to wipe WebKit website data on identity change
|
||||
*
|
||||
* See webkit_data.h for the rationale. This is Phase 0 of
|
||||
* plans/webkit-data-isolation.md: wipe the shared data manager in place on
|
||||
* logout / identity switch so the next user does not inherit the previous
|
||||
* user's cookies, cache, localStorage, service workers, etc.
|
||||
*/
|
||||
|
||||
#include "webkit_data.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
/* ── Async clear state ─────────────────────────────────────────────── *
|
||||
* webkit_website_data_manager_clear() is asynchronous. We drive a nested
|
||||
* GLib main loop until the clear callback fires (or a 5s timeout expires)
|
||||
* so callers can treat webkit_data_clear_all() as synchronous.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
gboolean done;
|
||||
gboolean timed_out;
|
||||
} clear_state_t;
|
||||
|
||||
static void on_clear_finished(GObject *source, GAsyncResult *res,
|
||||
gpointer user_data) {
|
||||
(void)source;
|
||||
(void)res;
|
||||
clear_state_t *st = (clear_state_t *)user_data;
|
||||
st->done = TRUE;
|
||||
}
|
||||
|
||||
static gboolean on_clear_timeout(gpointer user_data) {
|
||||
clear_state_t *st = (clear_state_t *)user_data;
|
||||
if (!st->done) {
|
||||
st->timed_out = TRUE;
|
||||
st->done = TRUE;
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons) {
|
||||
if (ctx == NULL) {
|
||||
ctx = webkit_web_context_get_default();
|
||||
}
|
||||
if (ctx == NULL) {
|
||||
g_printerr("[webkit-data] No WebKitWebContext to clear\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
WebKitWebsiteDataManager *dm =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
if (dm == NULL) {
|
||||
g_printerr("[webkit-data] No WebKitWebsiteDataManager on context\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Build the set of data types to clear. WEBKIT_WEBSITE_DATA_ALL covers:
|
||||
* memory cache, disk cache, offline app cache, cookies, local storage,
|
||||
* session storage, IndexedDB, WebSQL, service worker registrations,
|
||||
* HSTS, plugin data, device id/hash salt. */
|
||||
WebKitWebsiteDataTypes types = WEBKIT_WEBSITE_DATA_ALL;
|
||||
|
||||
/* Drive the async clear to completion. The clear callback and the
|
||||
* timeout fire on the default main context (where WebKit schedules
|
||||
* its async completions), so we pump the default context — NOT a
|
||||
* fresh nested context, which would never see the callback.
|
||||
*
|
||||
* We do NOT push a thread-default context because the caller (the
|
||||
* MCP request handler) is already running inside the default main
|
||||
* loop; iterating the default context here lets the clear callback
|
||||
* fire while still on the same thread. */
|
||||
clear_state_t st = { .done = FALSE, .timed_out = FALSE };
|
||||
guint timeout_id = g_timeout_add_seconds(5, on_clear_timeout, &st);
|
||||
|
||||
webkit_website_data_manager_clear(dm, types, 0, NULL,
|
||||
on_clear_finished, &st);
|
||||
|
||||
/* Pump the default context until the callback or timeout sets
|
||||
* st.done. g_main_context_iteration may return FALSE if no sources
|
||||
* are ready, so we loop on the flag, not the return value. */
|
||||
while (!st.done) {
|
||||
g_main_context_iteration(NULL, TRUE);
|
||||
}
|
||||
|
||||
if (timeout_id != 0) {
|
||||
g_source_remove(timeout_id);
|
||||
timeout_id = 0;
|
||||
}
|
||||
|
||||
if (st.timed_out) {
|
||||
g_printerr("[webkit-data] clear timed out after 5s\n");
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* Favicons live in a separate WebKitFaviconDatabase, not the data
|
||||
* manager. Clearing it is optional because it is process-global and
|
||||
* not identity-sensitive (favicons are page icons, not user data).
|
||||
* When requested, clear it so a switched user doesn't see the prior
|
||||
* user's visited-site icons in the tab strip. */
|
||||
if (clear_favicons) {
|
||||
WebKitFaviconDatabase *favdb =
|
||||
webkit_web_context_get_favicon_database(ctx);
|
||||
if (favdb != NULL) {
|
||||
webkit_favicon_database_clear(favdb);
|
||||
}
|
||||
}
|
||||
|
||||
g_print("[webkit-data] Cleared all website data (favicons=%s)\n",
|
||||
clear_favicons ? "yes" : "no");
|
||||
return 0;
|
||||
}
|
||||
54
src/webkit_data.h
Normal file
54
src/webkit_data.h
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* webkit_data.h — helpers to wipe WebKit website data on identity change
|
||||
*
|
||||
* WebKitGTK's WebKitWebsiteDataManager holds cookies, HTTP cache, localStorage,
|
||||
* IndexedDB, WebSQL, service worker registrations, offline app cache, HSTS,
|
||||
* and the favicon database for the whole process. When a user logs out or
|
||||
* switches Nostr identity, this data must be cleared so the next user does
|
||||
* not see the previous user's web session (cookies are the primary leak —
|
||||
* they identify you to web pages).
|
||||
*
|
||||
* Phase 0 of plans/webkit-data-isolation.md: a single shared data manager
|
||||
* (the default context's) is wiped in place. Phase A will replace this with
|
||||
* per-user data managers, at which point these helpers become the safety net
|
||||
* for the in-memory live-tab case.
|
||||
*/
|
||||
|
||||
#ifndef WEBKIT_DATA_H
|
||||
#define WEBKIT_DATA_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Clear ALL website data from the given WebKitWebContext's data manager:
|
||||
* cookies, disk + memory cache, local storage, session storage, IndexedDB,
|
||||
* WebSQL, service worker registrations, offline app cache, HSTS, plugin data,
|
||||
* and (optionally) favicons.
|
||||
*
|
||||
* ctx — the WebKitWebContext whose data manager to clear.
|
||||
* Pass NULL to target the default context.
|
||||
* clear_favicons — if TRUE, also clear the favicon database. The favicon
|
||||
* DB is process-global and shared across contexts; clearing
|
||||
* it drops every cached favicon, not just the current
|
||||
* site's. Pass FALSE if you only want session/cache
|
||||
* isolation (favicons are not identity-sensitive).
|
||||
*
|
||||
* This is asynchronous in WebKitGTK (the clear API takes a callback). This
|
||||
* function runs a nested GLib main loop until the clear completes (or 5s
|
||||
* elapses) so that on return the data is gone. It is safe to call from the
|
||||
* GTK main thread.
|
||||
*
|
||||
* Returns 0 on success, -1 if the context/data manager could not be resolved,
|
||||
* -2 if the clear timed out.
|
||||
*/
|
||||
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* WEBKIT_DATA_H */
|
||||
20
tests/local-site/404.html
Normal file
20
tests/local-site/404.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Missing Page Placeholder</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Missing Page Placeholder</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page exists. The home page link labeled "Missing Page" points
|
||||
to <code>404.html</code>, which is this file — so it is not actually
|
||||
missing. To test a real missing page, click
|
||||
<a href="does-not-exist.html">this link to a truly missing page</a>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
408
tests/local-site/LOCAL-FILE-BROWSING-REPORT.md
Normal file
408
tests/local-site/LOCAL-FILE-BROWSING-REPORT.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# Local File Browsing Assessment — sovereign_browser
|
||||
|
||||
**Date:** 2026-07-16
|
||||
**Test site:** [`tests/local-site/`](index.html)
|
||||
**Browser:** sovereign_browser (WebKitGTK + libsoup-3.0)
|
||||
**Goal:** Verify that sovereign_browser can load and operate a multipage
|
||||
website directly from the local filesystem (`file://`) with no web server,
|
||||
allowing cross-origin access between local files — as required by the
|
||||
"security moved to the Qube level" design.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Local file browsing in sovereign_browser works remarkably well.** The vast
|
||||
majority of edge cases pass. WebKitGTK's `file://` handler, combined with
|
||||
sovereign_browser's permissive local-file policy, allows a full multipage
|
||||
website to be opened and operated from disk just as if it were served over
|
||||
HTTP. Only a handful of minor issues were found, and most are WebKit engine
|
||||
quirks rather than sovereign_browser bugs.
|
||||
|
||||
**Verdict:** No blocking fixes are required for local-file browsing to work.
|
||||
The issues below are polish/UX improvements.
|
||||
|
||||
---
|
||||
|
||||
## Test Matrix
|
||||
|
||||
| # | Edge case | Page | Result | Notes |
|
||||
|---|-----------|------|--------|-------|
|
||||
| 1 | Relative links between sibling pages | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | Links resolve to correct `file://` URLs |
|
||||
| 2 | Links into subdirectories | [`index.html`](index.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
|
||||
| 3 | Links back out with `../` | [`subdir/page.html`](subdir/page.html) → [`index.html`](index.html) | ✅ PASS | |
|
||||
| 4 | Deeply nested paths (3 levels) | [`subdir/deep/deeper/deepest.html`](subdir/deep/deeper/deepest.html) | ✅ PASS | `../../../` resolves correctly |
|
||||
| 5 | External stylesheet (relative) | [`assets/site.css`](assets/site.css) | ✅ PASS | 21 CSS rules loaded on every page |
|
||||
| 6 | SVG favicon | [`assets/favicon.svg`](assets/favicon.svg) | ✅ PASS | `link[rel=icon]` href resolved |
|
||||
| 7 | SVG image via `<img>` | [`assets/logo.svg`](assets/logo.svg) | ✅ PASS | `naturalWidth` correct, `complete=true` |
|
||||
| 8 | `<picture>` + `<source srcset>` | [`gallery.html`](gallery.html) | ✅ PASS | `currentSrc` selected correctly |
|
||||
| 9 | `<img srcset>` with `1x/2x` | [`gallery.html`](gallery.html) | ✅ PASS | |
|
||||
| 10 | Lazy-loaded image (`loading="lazy"`) | [`gallery.html`](gallery.html) | ✅ PASS | |
|
||||
| 11 | External `<script src>` (sibling) | [`assets/home.js`](assets/home.js) | ✅ PASS | Script ran, updated DOM |
|
||||
| 12 | External `<script src>` (`../`) | [`assets/external.js`](assets/external.js) | ✅ PASS | Loaded from parent dir |
|
||||
| 13 | `fetch()` a local JSON file | [`data.html`](data.html) → [`assets/data.json`](assets/data.json) | ✅ PASS | `status=0` (file:// convention), body returned |
|
||||
| 14 | `XMLHttpRequest` a local file | [`data.html`](data.html) | ✅ PASS | `status=0`, responseText populated |
|
||||
| 15 | `fetch()` from a subdirectory | [`data.html`](data.html) → [`subdir/sub.json`](subdir/sub.json) | ✅ PASS | |
|
||||
| 16 | `fetch()` with query string | [`data.html`](data.html) → `assets/data.json?cache=bust` | ✅ PASS | Query ignored, file loaded |
|
||||
| 17 | `fetch()` a missing file | [`data.html`](data.html) → `assets/does-not-exist.json` | ⚠️ MINOR | `fetch()` throws "Load failed" instead of returning a 404 response. This is standard WebKit `file://` behavior — there is no HTTP status for local files. **Not a bug.** |
|
||||
| 18 | `<iframe>` loading a sibling page | [`iframe.html`](iframe.html) → [`about.html`](about.html) | ✅ PASS | `load` event fired, `readyState=complete` |
|
||||
| 19 | `<iframe>` loading a subdirectory page | [`iframe.html`](iframe.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
|
||||
| 20 | Cross-iframe DOM access (same-origin) | [`iframe.html`](iframe.html) | ✅ PASS | **Critical finding:** `contentDocument.title` readable — all `file://` URLs are treated as same-origin. This is exactly what we want for local development. |
|
||||
| 21 | `<iframe srcdoc>` | [`iframe.html`](iframe.html) | ✅ PASS | |
|
||||
| 22 | `<iframe src="data:...">` | [`iframe.html`](iframe.html) | ✅ PASS | |
|
||||
| 23 | GET form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated with `?name=Alice&age=30` |
|
||||
| 24 | POST form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated without query; POST body silently dropped (no server). Correct behavior. |
|
||||
| 25 | Form targeting a new window (`target="_blank"`) | [`form.html`](form.html) | ✅ PASS | Opens in new tab |
|
||||
| 26 | Form targeting an iframe (`target="form-frame"`) | [`form.html`](form.html) | ✅ PASS | |
|
||||
| 27 | `localStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work; length tracking correct |
|
||||
| 28 | `sessionStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work |
|
||||
| 29 | `document.cookie` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get work; multiple cookies visible |
|
||||
| 30 | `IndexedDB` | [`storage-test.html`](storage-test.html) | ✅ PASS | Open, upgrade (create object store), put, get, count — all work |
|
||||
| 30b | Cache API (CacheStorage) | [`storage-test.html`](storage-test.html) | ❌ FAIL | `cache.put()` rejects: "Request url is not HTTP/HTTPS". See Issue #5. |
|
||||
| 31 | `window.open()` a sibling page | [`popup.html`](popup.html) → [`about.html`](about.html) | ✅ PASS | Opens in new tab |
|
||||
| 32 | `window.open()` with features | [`popup.html`](popup.html) | ✅ PASS | |
|
||||
| 33 | `history.pushState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?pushed=1` |
|
||||
| 34 | `history.replaceState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?replaced=1` |
|
||||
| 35 | `history.back()` / `forward()` | browser `back`/`forward` MCP tools | ✅ PASS | Navigates correctly between file:// pages |
|
||||
| 36 | Hash fragment in URL | [`hash.html#section-2`](hash.html) | ✅ PASS | `location.hash` = `#section-2` |
|
||||
| 37 | `hashchange` event | [`hash.html`](hash.html) | ✅ PASS | (page wired correctly) |
|
||||
| 38 | URL-encoded filename (`encoded%20name.html`) | [`encoded name.html`](encoded%20name.html) | ✅ PASS | Percent-encoding decoded, correct file loaded |
|
||||
| 39 | Query string on file:// URL | [`query.html?foo=bar&baz=qux`](query.html) | ✅ PASS | File loaded, `location.search` preserved, `URLSearchParams` works |
|
||||
| 40 | Missing page (404 equivalent) | `does-not-exist.html` | ✅ FIXED | URL bar now shows failed URL (Issue #1 fixed) |
|
||||
| 41 | `<video>` with local MP4 source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=3, duration=596.5s, 640x360, no errors |
|
||||
| 42 | `<audio>` with local M4A source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=4, duration=3277s, no errors |
|
||||
| 42b | Missing video source (error event test) | [`media.html`](media.html) | ⚠️ MINOR | `loadstart` fires but `error` event does not. See Issue #2. |
|
||||
| 43 | `<object>` / `<embed>` with SVG | [`media.html`](media.html) | ✅ PASS | `data`/`src` resolved to file:// URL |
|
||||
| 44 | Inline SVG | [`media.html`](media.html) | ✅ PASS | |
|
||||
| 45 | Clicking a link to navigate | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | `click` MCP tool triggered navigation |
|
||||
| 46 | ES Modules (`import()`) | [`advanced.html`](advanced.html) | ✅ PASS | Dynamic `import()` works; exports accessible (greet, add, PI) |
|
||||
| 47 | Web Workers | [`advanced.html`](advanced.html) | ✅ PASS | `new Worker()` created; `postMessage` round-trip works (7*6=42) |
|
||||
| 47b | Shared Workers | [`advanced.html`](advanced.html) | ✅ PASS | `new SharedWorker()` created; `port.onmessage` works; ping/pong round-trip works |
|
||||
| 48 | Service Workers | [`advanced.html`](advanced.html) | ❌ FAIL | `register()` rejects: "must be called with HTTP or HTTPS". See Issue #6. |
|
||||
| 49 | WebAssembly | [`advanced.html`](advanced.html) | ✅ PASS | `WebAssembly.instantiate()` works; `add(3,4)=7`, `add(100,200)=300` |
|
||||
| 50 | Cross-origin fetch (file:// → https://) | [`advanced.html`](advanced.html) | ✅ PASS | `fetch('https://example.com')` returns status=200, type=basic, 559 chars. **No CORS blocking!** |
|
||||
| 51 | Cross-origin remote image | [`advanced.html`](advanced.html) | ✅ PASS | Remote images from placehold.co and google.com load correctly |
|
||||
| 52 | Cross-origin remote script | [`advanced.html`](advanced.html) | ✅ PASS | Remote `<script src>` from jsdelivr CDN loaded and executed |
|
||||
| 53 | Cross-origin remote stylesheet | [`advanced.html`](advanced.html) | ✅ PASS | Remote `<link rel=stylesheet>` from jsdelivr CDN loaded and applied |
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Issue #1 — Missing page falls back to `about:blank` (FIXED ✅)
|
||||
|
||||
**What was happening:** When navigating to a `file://` URL that does not
|
||||
exist, the browser displayed an error message but the URL bar showed
|
||||
`about:blank` instead of the requested URL.
|
||||
|
||||
**Fix applied:** Updated [`on_load_failed()`](src/tab_manager.c:1335) in
|
||||
`src/tab_manager.c` to:
|
||||
1. Accept the `tab_info_t*` as `user_data` (changed signal connection from
|
||||
`NULL` to `tab`).
|
||||
2. Set the URL bar text to `failing_uri` (skipping `about:blank`).
|
||||
3. Update `tab->current_url` to the failing URI.
|
||||
4. Set the tab title to the error message so the tab bar shows something
|
||||
meaningful instead of "Loading…".
|
||||
|
||||
**Verified:** After the fix, navigating to a missing file shows the failed
|
||||
`file://` URL in the address bar (confirmed via `get_url` MCP tool).
|
||||
|
||||
---
|
||||
|
||||
### Issue #2 — `<video>`/`<audio>` error events don't fire for missing sources (MINOR / WebKit quirk)
|
||||
|
||||
**Note: Media playback with valid files works perfectly.** Tested with a
|
||||
real MP4 video (Big Buck Bunny, 596s, 640x360) and M4A audio (Neon Dream,
|
||||
3277s). Both reach `can play` state with correct duration and dimensions.
|
||||
This issue only affects the error path when a media file is missing.
|
||||
|
||||
**What happens:** When a `<video>` or `<audio>` element references a local
|
||||
file that doesn't exist, the `error` event does not fire. The `loadstart`
|
||||
event fires, but then the load silently fails without an error event. The
|
||||
element stays in a "not loaded" state indefinitely.
|
||||
|
||||
**Why it matters:** Web apps that rely on `error` events to show fallback
|
||||
content or retry logic won't work correctly for missing media files.
|
||||
|
||||
**Likely cause:** This is a WebKitGTK engine behavior, not a
|
||||
sovereign_browser bug. The `file://` loader may not emit media element
|
||||
errors the same way the HTTP loader does.
|
||||
|
||||
**Suggested fix:** None required — this is upstream WebKit behavior. If it
|
||||
becomes a problem, a workaround would be to check `networkState` or
|
||||
`readyState` after a timeout. Not worth fixing for the local-file use case.
|
||||
|
||||
**Severity:** Very low. Only affects missing media files, which is an edge
|
||||
case.
|
||||
|
||||
---
|
||||
|
||||
### Issue #3 — `fetch()` for missing files throws instead of returning an error response (MINOR / WebKit quirk)
|
||||
|
||||
**What happens:** `fetch('missing.json')` on a `file://` page throws a
|
||||
`TypeError: Load failed` exception rather than returning a Response with
|
||||
`ok=false` and a status code.
|
||||
|
||||
**Why it matters:** Code using `fetch().then(r => r.ok ? ... : ...)` will
|
||||
hit the catch branch instead of the error-handling branch. This is
|
||||
different from HTTP behavior.
|
||||
|
||||
**Likely cause:** Standard WebKit `file://` behavior — there's no HTTP
|
||||
status code to return for a missing local file, so the fetch promise
|
||||
rejects.
|
||||
|
||||
**Suggested fix:** None required — this is upstream WebKit behavior and is
|
||||
consistent with Safari and other WebKit-based browsers. Document it as a
|
||||
known difference from HTTP.
|
||||
|
||||
**Severity:** Very low. Developers writing local-file apps should use
|
||||
try/catch with fetch.
|
||||
|
||||
---
|
||||
|
||||
### Issue #4 — Page title shows `(none)` in browser log on initial load (FIXED ✅)
|
||||
|
||||
**What was happening:** The browser log frequently showed
|
||||
`[loaded] file://... -- title: (none)` even for pages that have a `<title>`.
|
||||
The title arrived slightly after the `LOAD_FINISHED` event.
|
||||
|
||||
**Fix applied:**
|
||||
1. Added a new [`on_title_changed()`](src/tab_manager.c:1361) handler
|
||||
connected to the webview's `notify::title` signal. It logs the title
|
||||
as `[title] URL -- Title` when the title actually becomes available,
|
||||
and updates the tab title.
|
||||
2. Simplified the `[loaded]` log line in `on_load_changed()` to just log
|
||||
the URL (no more `title: (none)` noise). The title is now logged
|
||||
separately via the `notify::title` handler.
|
||||
|
||||
**Verified:** After the fix, the log shows:
|
||||
```
|
||||
[title] file:///...index.html -- Local Site — Home
|
||||
[loaded] file:///...index.html
|
||||
```
|
||||
instead of the old `[loaded] ... -- title: (none)`.
|
||||
|
||||
---
|
||||
|
||||
### Issue #5 — Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs (MINOR / WebKit restriction)
|
||||
|
||||
**What happens:** `caches.open('my_cache')` succeeds, but
|
||||
`cache.put(request, response)` rejects with the error:
|
||||
`"Request url is not HTTP/HTTPS"` when the request URL is not an HTTP/HTTPS
|
||||
URL. This means the Cache API cannot be used to store entries keyed by
|
||||
`file://` URLs or custom schemes.
|
||||
|
||||
**Why it matters:** Web apps that use the Cache API for offline storage
|
||||
(e.g., service workers caching responses) will not work on `file://` pages.
|
||||
The Cache API is the only standard browser storage that has this restriction
|
||||
— localStorage, sessionStorage, IndexedDB, and cookies all work fine on
|
||||
`file://`.
|
||||
|
||||
**Likely cause:** This is a WebKit engine restriction. The Cache API
|
||||
spec requires that request URLs be HTTP or HTTPS. WebKit enforces this
|
||||
at the `cache.put()` level. This is consistent with Chrome and Firefox
|
||||
behavior — the Cache API is designed for service worker contexts which
|
||||
only exist on HTTP/HTTPS origins.
|
||||
|
||||
**Suggested fix:** None required — this is a fundamental WebKit/W3C
|
||||
specification restriction, not a sovereign_browser bug. Local-file web
|
||||
apps that need key-value storage should use IndexedDB instead, which
|
||||
works perfectly on `file://`.
|
||||
|
||||
**Severity:** Low. Only affects apps specifically using the Cache API,
|
||||
which is a niche use case for local-file apps. IndexedDB is the
|
||||
recommended alternative and works fully.
|
||||
|
||||
---
|
||||
|
||||
### Issue #6 — Service Workers don't work on file:// (MINOR / WebKit restriction)
|
||||
|
||||
**What happens:** `navigator.serviceWorker.register()` rejects with the
|
||||
error: "serviceWorker.register() must be called with a script URL whose
|
||||
protocol is either HTTP or HTTPS".
|
||||
|
||||
**Why it matters:** Service Workers cannot be used on `file://` pages.
|
||||
This means PWA features like offline caching via service workers,
|
||||
background sync, and push notifications won't work for local-file apps.
|
||||
|
||||
**Likely cause:** This is a W3C specification restriction — Service
|
||||
Workers are only allowed on HTTP/HTTPS origins. WebKit enforces this at
|
||||
the `register()` level. This is consistent with all major browsers.
|
||||
|
||||
**Suggested fix:** None required — this is a fundamental specification
|
||||
restriction. Web Workers (regular, non-service workers) DO work on
|
||||
`file://` and can be used for background computation. For offline
|
||||
storage, use IndexedDB.
|
||||
|
||||
**Severity:** Low. Service Workers are a niche use case for local-file
|
||||
apps. Web Workers are the alternative for background processing and work
|
||||
fully.
|
||||
|
||||
---
|
||||
|
||||
## What Works Surprisingly Well
|
||||
|
||||
These are the things that could have been broken but weren't:
|
||||
|
||||
1. **Cross-origin access between all `file://` URLs** — iframes can access
|
||||
their parent's DOM and vice versa, `window.open()` popups can be
|
||||
accessed, and `fetch()`/XHR work across directories. This is the single
|
||||
most important requirement for local-file web apps and it works
|
||||
perfectly. WebKitGTK treats all `file://` URLs as same-origin.
|
||||
|
||||
2. **Relative path resolution** — `../`, `../../`, and `../../../` all work
|
||||
correctly for links, images, stylesheets, scripts, and fetch targets.
|
||||
|
||||
3. **URL-encoded filenames** — `encoded%20name.html` (file on disk:
|
||||
`encoded name.html`) loads correctly.
|
||||
|
||||
4. **Query strings and hash fragments** — preserved in `location.href`,
|
||||
accessible via `location.search` / `location.hash`, and don't break file
|
||||
loading.
|
||||
|
||||
5. **`history.pushState` / `replaceState`** — work on `file://` URLs,
|
||||
allowing SPA-style routing for local apps.
|
||||
|
||||
6. **All browser storage except Cache API** — `localStorage`,
|
||||
`sessionStorage`, `cookies`, and `IndexedDB` all work fully on
|
||||
`file://`. IndexedDB supports open, upgrade, object stores, put, get,
|
||||
and count operations. Only the Cache API (CacheStorage) fails because
|
||||
WebKit requires HTTP/HTTPS URLs for cache entries (Issue #5). Use
|
||||
IndexedDB as the recommended storage for local-file apps.
|
||||
|
||||
7. **`<picture>` / `srcset`** — responsive image selection works.
|
||||
|
||||
8. **Form submissions** — both GET and POST navigate to the target local
|
||||
file. GET appends query strings; POST silently drops the body (correct,
|
||||
since there's no server).
|
||||
|
||||
9. **Video and audio playback** — both MP4 video and M4A audio play
|
||||
correctly from `file://` URLs. The `<video>` element reports correct
|
||||
dimensions (640x360), duration (596.5s), and reaches `can play` state.
|
||||
The `<audio>` element similarly works with correct duration. Media
|
||||
playback is fully functional for local files.
|
||||
|
||||
10. **ES Modules** — dynamic `import()` works on `file://` pages, allowing
|
||||
modern modular JavaScript with local module files.
|
||||
|
||||
11. **Web Workers and Shared Workers** — `new Worker()` and
|
||||
`new SharedWorker()` with local script files both work, enabling
|
||||
background computation threads and cross-tab shared workers from
|
||||
`file://` pages.
|
||||
|
||||
12. **WebAssembly** — `WebAssembly.instantiate()` works, enabling
|
||||
high-performance compiled code on `file://` pages.
|
||||
|
||||
13. **Cross-origin fetch to remote HTTPS URLs** — `fetch('https://example.com')`
|
||||
from a `file://` page returns `status=200, type=basic` with no CORS
|
||||
blocking. This confirms the "deprecated web security" design goal is
|
||||
working — local web apps can freely call remote APIs.
|
||||
|
||||
14. **Cross-origin remote resources** — remote `<script src>`, `<link
|
||||
rel=stylesheet>`, and `<img>` from HTTPS CDNs all load and execute
|
||||
correctly from `file://` pages.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### No blocking fixes needed
|
||||
|
||||
The local-file browsing feature works well enough for production use as-is.
|
||||
The issues found are minor UX/engine-quirk issues, not functional blockers.
|
||||
|
||||
### Optional improvements (in priority order)
|
||||
|
||||
1. **~~Fix Issue #1 (about:blank on load failure)~~** — ✅ FIXED. The
|
||||
failed URL now stays in the address bar, and the tab title shows the
|
||||
error message.
|
||||
|
||||
2. **Document the `fetch()`-throws-on-missing-file behavior** — Add a note
|
||||
to the docs or a FAQ that `fetch()` on `file://` rejects (throws) for
|
||||
missing files rather than returning an HTTP error response.
|
||||
|
||||
3. **Document the Cache API limitation (Issue #5)** — Note in the docs that
|
||||
the Cache API (CacheStorage) does not work on `file://` pages because
|
||||
WebKit requires HTTP/HTTPS URLs for cache entries. Recommend IndexedDB
|
||||
as the alternative for local-file apps needing structured storage. (Not
|
||||
a real problem for local files since everything is already local — no
|
||||
need to cache.)
|
||||
|
||||
4. **~~Fix Issue #4 (title logging)~~** — ✅ FIXED. Added `notify::title`
|
||||
handler; log now shows `[title] URL -- Title` when the title arrives,
|
||||
and `[loaded] URL` without the noisy `title: (none)`.
|
||||
|
||||
5. **Issue #2 (media error events)** — No action needed; upstream WebKit
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
```bash
|
||||
# Start the browser with auto-login and open the test site
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///home/user/lt/sovereign_browser/tests/local-site/index.html"
|
||||
|
||||
# Navigate via MCP (examples):
|
||||
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":"file:///home/user/lt/sovereign_browser/tests/local-site/data.html"}}}'
|
||||
|
||||
# Check fetch results:
|
||||
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"eval","arguments":{"script":"document.getElementById(\"fetch-out\").textContent"}}}'
|
||||
|
||||
# Test a missing 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":"file:///home/user/lt/sovereign_browser/tests/local-site/does-not-exist.html"}}}'
|
||||
# Then check: get_url returns "about:blank" (Issue #1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Site Structure
|
||||
|
||||
```
|
||||
tests/local-site/
|
||||
├── index.html # Home page with links to all pages
|
||||
├── about.html # Sibling page, anchor links
|
||||
├── contact.html # Parent-relative link test
|
||||
├── gallery.html # Images, picture, srcset, lazy load
|
||||
├── data.html # fetch() + XHR + JSON + missing file + query
|
||||
├── media.html # video, audio, object, embed, inline SVG
|
||||
├── iframe.html # iframes (sibling, subdir, srcdoc, data:), cross-frame access
|
||||
├── form.html # GET/POST forms, target=_blank, target=iframe
|
||||
├── form-target.html # Form submission target
|
||||
├── storage.html # Storage page with buttons (localStorage, sessionStorage, IndexedDB, cookies)
|
||||
├── storage-test.html # Automated storage test (all types incl. Cache API, writes results to DOM)
|
||||
├── popup.html # window.open() tests
|
||||
├── history.html # pushState, replaceState, back, hashchange
|
||||
├── hash.html # Hash fragment navigation
|
||||
├── query.html # Query string handling
|
||||
├── encoded name.html # URL-encoded filename (literal space)
|
||||
├── 404.html # Placeholder (not actually missing)
|
||||
├── advanced.html # Advanced JS test (ES Modules, Workers, WASM, cross-origin fetch)
|
||||
├── assets/
|
||||
│ ├── site.css # Shared stylesheet
|
||||
│ ├── favicon.svg # SVG favicon
|
||||
│ ├── logo.svg # SVG image
|
||||
│ ├── pic.svg # SVG image
|
||||
│ ├── data.json # JSON for fetch/XHR tests
|
||||
│ ├── home.js # External script for index.html
|
||||
│ ├── external.js # External script for scripts/external.html
|
||||
│ ├── advanced-runner.js # Advanced JS test runner
|
||||
│ ├── es-module-test.js # ES Module (import/export) test
|
||||
│ ├── worker-test.js # Web Worker script
|
||||
│ ├── shared-worker-test.js # Shared Worker script
|
||||
│ ├── sw-test.js # Service Worker script (expected to fail on file://)
|
||||
│ ├── sample.mp4 # Test video (Big Buck Bunny, gitignored)
|
||||
│ └── Neon Dream.m4a # Test audio (gitignored)
|
||||
├── scripts/
|
||||
│ └── external.html # Page in a subdirectory loading ../assets/external.js
|
||||
└── subdir/
|
||||
├── page.html # Subdirectory page with ../ links
|
||||
├── sub.json # JSON for subdir fetch test
|
||||
└── deep/
|
||||
└── deeper/
|
||||
└── deepest.html # 3-levels-deep page with ../../../ links
|
||||
```
|
||||
34
tests/local-site/about.html
Normal file
34
tests/local-site/about.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — About</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>About</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="about.html">About</a>
|
||||
<a href="contact.html">Contact</a>
|
||||
<a href="gallery.html">Gallery</a>
|
||||
<a href="data.html">Data</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This is the About page. It links back to the
|
||||
<a href="index.html">home page</a> and to a
|
||||
<a href="subdir/page.html">subdirectory page</a>.</p>
|
||||
|
||||
<h2>Nested relative link</h2>
|
||||
<p>From here, a link to a deeply nested page:
|
||||
<a href="subdir/deep/deeper/deepest.html">Deepest page</a>.</p>
|
||||
|
||||
<h2>Anchor on this page</h2>
|
||||
<p>Jump to <a href="#bottom">the bottom</a>.</p>
|
||||
|
||||
<p id="bottom">This is the bottom of the About page.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
19
tests/local-site/advanced.html
Normal file
19
tests/local-site/advanced.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Advanced JS Test</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Advanced JS Test Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Results</h2>
|
||||
<pre id="results">Running tests...</pre>
|
||||
</main>
|
||||
<script src="assets/advanced-runner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
247
tests/local-site/assets/advanced-runner.js
Normal file
247
tests/local-site/assets/advanced-runner.js
Normal file
@@ -0,0 +1,247 @@
|
||||
// Advanced JS test runner — writes results to #results as tests complete.
|
||||
(function () {
|
||||
var log = [];
|
||||
function flush() {
|
||||
var el = document.getElementById('results');
|
||||
if (el) el.textContent = log.join('\n');
|
||||
}
|
||||
function add(msg) {
|
||||
log.push(msg);
|
||||
flush();
|
||||
}
|
||||
|
||||
add('=== Advanced JS Test Start ===');
|
||||
add('URL: ' + location.href);
|
||||
add('');
|
||||
|
||||
// ── 1. ES Modules ──────────────────────────────────────────────
|
||||
add('--- ES Modules ---');
|
||||
add('Testing <script type="module"> with local import...');
|
||||
|
||||
// We'll use a dynamic import() to test module loading
|
||||
try {
|
||||
import('./es-module-test.js')
|
||||
.then(function (mod) {
|
||||
add('import(): ok');
|
||||
add(' mod.greet("World") = ' + mod.greet('World'));
|
||||
add(' mod.add(2, 3) = ' + mod.add(2, 3));
|
||||
add(' mod.PI = ' + mod.PI);
|
||||
add('ES MODULES: PASS');
|
||||
testWebWorker();
|
||||
})
|
||||
.catch(function (e) {
|
||||
add('import(): ERROR - ' + e.message);
|
||||
add('ES MODULES: FAIL');
|
||||
testWebWorker();
|
||||
});
|
||||
} catch (e) {
|
||||
add('import() not supported: ' + e.message);
|
||||
add('ES MODULES: FAIL (import() not supported)');
|
||||
testWebWorker();
|
||||
}
|
||||
|
||||
// ── 2. Web Workers ─────────────────────────────────────────────
|
||||
function testWebWorker() {
|
||||
add('');
|
||||
add('--- Web Workers ---');
|
||||
add('Testing new Worker() with local script...');
|
||||
|
||||
try {
|
||||
var worker = new Worker('assets/worker-test.js');
|
||||
add('new Worker(): ok (created)');
|
||||
|
||||
worker.onmessage = function (e) {
|
||||
add('worker.onmessage: ' + e.data);
|
||||
add('WEB WORKERS: PASS');
|
||||
worker.terminate();
|
||||
testServiceWorker();
|
||||
};
|
||||
|
||||
worker.onerror = function (e) {
|
||||
add('worker.onerror: ' + (e.message || 'error'));
|
||||
add('WEB WORKERS: FAIL');
|
||||
testServiceWorker();
|
||||
};
|
||||
|
||||
// Send a message to the worker
|
||||
worker.postMessage({ cmd: 'compute', a: 7, b: 6 });
|
||||
add('postMessage sent: {cmd: "compute", a: 7, b: 6}');
|
||||
} catch (e) {
|
||||
add('new Worker(): ERROR - ' + e.message);
|
||||
add('WEB WORKERS: FAIL');
|
||||
testServiceWorker();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Service Workers ─────────────────────────────────────────
|
||||
function testServiceWorker() {
|
||||
add('');
|
||||
add('--- Service Workers ---');
|
||||
add('available: ' + ('serviceWorker' in navigator));
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
add('SERVICE WORKERS: not available');
|
||||
testWebAssembly();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.serviceWorker.register('assets/sw-test.js')
|
||||
.then(function (reg) {
|
||||
add('register(): ok (scope=' + reg.scope + ')');
|
||||
add('SERVICE WORKERS: PASS (registered)');
|
||||
testWebAssembly();
|
||||
})
|
||||
.catch(function (e) {
|
||||
add('register(): ERROR - ' + (e.message || e));
|
||||
add('SERVICE WORKERS: FAIL');
|
||||
testWebAssembly();
|
||||
});
|
||||
} catch (e) {
|
||||
add('register(): EXCEPTION - ' + e.message);
|
||||
add('SERVICE WORKERS: FAIL');
|
||||
testWebAssembly();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. WebAssembly ─────────────────────────────────────────────
|
||||
function testWebAssembly() {
|
||||
add('');
|
||||
add('--- WebAssembly ---');
|
||||
add('available: ' + (typeof WebAssembly !== 'undefined'));
|
||||
|
||||
if (typeof WebAssembly === 'undefined') {
|
||||
add('WEBASSEMBLY: not available');
|
||||
testCrossOriginFetch();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Minimal Wasm module: exports an "add" function
|
||||
// (wasm binary: (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)))
|
||||
var bytes = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,
|
||||
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b
|
||||
]);
|
||||
|
||||
WebAssembly.instantiate(bytes).then(function (result) {
|
||||
var addFn = result.instance.exports.add;
|
||||
add('instantiate(): ok');
|
||||
add(' add(3, 4) = ' + addFn(3, 4));
|
||||
add(' add(100, 200) = ' + addFn(100, 200));
|
||||
add('WEBASSEMBLY: PASS');
|
||||
testCrossOriginFetch();
|
||||
}).catch(function (e) {
|
||||
add('instantiate(): ERROR - ' + e.message);
|
||||
add('WEBASSEMBLY: FAIL');
|
||||
testCrossOriginFetch();
|
||||
});
|
||||
} catch (e) {
|
||||
add('instantiate(): EXCEPTION - ' + e.message);
|
||||
add('WEBASSEMBLY: FAIL');
|
||||
testCrossOriginFetch();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Cross-origin fetch to remote HTTP/HTTPS ─────────────────
|
||||
function testCrossOriginFetch() {
|
||||
add('');
|
||||
add('--- Cross-origin fetch (file:// → https://) ---');
|
||||
|
||||
// Test 1: fetch a remote page
|
||||
add('Test 1: fetch("https://example.com")...');
|
||||
try {
|
||||
fetch('https://example.com')
|
||||
.then(function (r) {
|
||||
add(' status=' + r.status + ' ok=' + r.ok + ' type=' + r.type);
|
||||
return r.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
add(' body length=' + text.length + ' chars');
|
||||
add(' body starts with: ' + text.substring(0, 80).replace(/\n/g, ' '));
|
||||
add('CROSS-ORIGIN FETCH (page): PASS');
|
||||
testRemoteImage();
|
||||
})
|
||||
.catch(function (e) {
|
||||
add(' ERROR - ' + e.message);
|
||||
add('CROSS-ORIGIN FETCH (page): FAIL');
|
||||
testRemoteImage();
|
||||
});
|
||||
} catch (e) {
|
||||
add(' EXCEPTION - ' + e.message);
|
||||
add('CROSS-ORIGIN FETCH (page): FAIL');
|
||||
testRemoteImage();
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: load a remote image
|
||||
function testRemoteImage() {
|
||||
add('');
|
||||
add('Test 2: remote image from https://...');
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
add(' loaded: ' + img.naturalWidth + 'x' + img.naturalHeight);
|
||||
add('CROSS-ORIGIN IMAGE: PASS');
|
||||
testRemoteScript();
|
||||
};
|
||||
img.onerror = function () {
|
||||
add(' ERROR: image failed to load');
|
||||
add('CROSS-ORIGIN IMAGE: FAIL');
|
||||
testRemoteScript();
|
||||
};
|
||||
img.src = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Banana-Single.jpg/240px-Banana-Single.jpg';
|
||||
add(' img.src set to remote URL...');
|
||||
}
|
||||
|
||||
// Test 3: load a remote script
|
||||
function testRemoteScript() {
|
||||
add('');
|
||||
add('Test 3: remote <script src> from https://...');
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js';
|
||||
s.onload = function () {
|
||||
add(' loaded: script executed');
|
||||
add(' typeof confetti = ' + (typeof confetti));
|
||||
add('CROSS-ORIGIN SCRIPT: PASS');
|
||||
testRemoteStylesheet();
|
||||
};
|
||||
s.onerror = function () {
|
||||
add(' ERROR: script failed to load');
|
||||
add('CROSS-ORIGIN SCRIPT: FAIL');
|
||||
testRemoteStylesheet();
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
add(' script.src set to remote URL...');
|
||||
}
|
||||
|
||||
// Test 4: load a remote stylesheet
|
||||
function testRemoteStylesheet() {
|
||||
add('');
|
||||
add('Test 4: remote <link rel=stylesheet> from https://...');
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdn.jsdelivr.net/npm/water.css@2/out/light.css';
|
||||
link.onload = function () {
|
||||
add(' loaded: stylesheet applied');
|
||||
add(' stylesheet count = ' + document.styleSheets.length);
|
||||
add('CROSS-ORIGIN STYLESHEET: PASS');
|
||||
finishAll();
|
||||
};
|
||||
link.onerror = function () {
|
||||
add(' ERROR: stylesheet failed to load');
|
||||
add('CROSS-ORIGIN STYLESHEET: FAIL');
|
||||
finishAll();
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
add(' link.href set to remote URL...');
|
||||
}
|
||||
|
||||
function finishAll() {
|
||||
add('');
|
||||
add('=== Advanced JS Test Complete ===');
|
||||
flush();
|
||||
}
|
||||
})();
|
||||
13
tests/local-site/assets/data.json
Normal file
13
tests/local-site/assets/data.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"source": "assets/data.json",
|
||||
"loaded": true,
|
||||
"items": [
|
||||
{ "id": 1, "name": "alpha" },
|
||||
{ "id": 2, "name": "beta" },
|
||||
{ "id": 3, "name": "gamma" }
|
||||
],
|
||||
"meta": {
|
||||
"generated": "2026-07-16",
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
16
tests/local-site/assets/es-module-test.js
Normal file
16
tests/local-site/assets/es-module-test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// ES Module test — imported by advanced-runner.js via dynamic import()
|
||||
export const PI = 3.14159;
|
||||
|
||||
export function greet(name) {
|
||||
return 'Hello, ' + name + '!';
|
||||
}
|
||||
|
||||
export function add(a, b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export default {
|
||||
PI: PI,
|
||||
greet: greet,
|
||||
add: add
|
||||
};
|
||||
5
tests/local-site/assets/external.js
Normal file
5
tests/local-site/assets/external.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// External script loaded by scripts/external.html via ../assets/external.js
|
||||
(function () {
|
||||
var out = document.getElementById('ext-out');
|
||||
if (out) out.textContent = 'External script ../assets/external.js ran successfully @ ' + new Date().toISOString();
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user