Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072915ef51 | ||
|
|
e482af8729 | ||
|
|
6f639cd419 | ||
|
|
6020df2e92 | ||
|
|
ae102323f6 | ||
|
|
b393308b2e | ||
|
|
0a1b117746 | ||
|
|
5150b7282a |
+15
@@ -13,3 +13,18 @@ build/
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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/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
|
||||
|
||||
$(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
|
||||
|
||||
@@ -29,27 +29,72 @@ 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:
|
||||
|
||||
```
|
||||
[loaded] https://laantungir.net/ -- title: Laan Tungir
|
||||
```
|
||||
- **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.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -58,9 +103,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 +172,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 +210,7 @@ credentials.
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ WebKitGTK (system lib, ~35 MB) │
|
||||
│ Blink-grade renderer + V8 + libsoup network stack │
|
||||
│ WebCore renderer + JavaScriptCore + libsoup net │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -175,8 +226,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 +293,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
|
||||
|
||||
|
||||
+39
@@ -125,6 +125,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
|
||||
|
||||
Executable
+132
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
+33
-11
@@ -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.",
|
||||
@@ -548,13 +545,38 @@ 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\"]}"},
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
+208
-20
@@ -11,9 +11,11 @@
|
||||
#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 <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -59,8 +61,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 +110,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 +448,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 +478,75 @@ 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_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;
|
||||
}
|
||||
}
|
||||
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 +554,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 +565,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1652,13 +1773,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 +1851,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 +1868,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 +1884,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);
|
||||
}
|
||||
|
||||
@@ -4068,6 +4248,14 @@ 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);
|
||||
}
|
||||
|
||||
/* Check login requirement for known tools. */
|
||||
gboolean requires_login = TRUE;
|
||||
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 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);
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
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 */
|
||||
+103
-2
@@ -36,6 +36,7 @@
|
||||
#include "key_store.h"
|
||||
#include "login_dialog.h"
|
||||
#include "nostr_bridge.h"
|
||||
#include "nostr_scheme.h"
|
||||
#include "nostr_inject.h"
|
||||
#include "history.h"
|
||||
#include "settings.h"
|
||||
@@ -50,6 +51,9 @@
|
||||
#include "profile.h"
|
||||
#include "relay_fetch.h"
|
||||
#include "bookmarks.h"
|
||||
#include "agent_conversations.h"
|
||||
#include "agent_skills.h"
|
||||
#include "net_services.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -89,6 +93,8 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
/* 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
|
||||
@@ -110,6 +116,7 @@ void app_clear_signer(void) {
|
||||
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; }
|
||||
@@ -253,6 +260,22 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
|
||||
}
|
||||
}
|
||||
|
||||
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
|
||||
* the sovereign://agents internal page, which 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_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://agents");
|
||||
} else {
|
||||
/* No active tab — open a new one pointed at the agent page. */
|
||||
tab_manager_new_tab("sovereign://agents");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- *
|
||||
* All browser-level shortcuts are configurable via the shortcuts module.
|
||||
* The on_key_press handler looks up the action for the pressed key
|
||||
@@ -260,12 +283,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 +330,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;
|
||||
@@ -361,6 +420,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;
|
||||
}
|
||||
@@ -396,6 +471,9 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
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();
|
||||
|
||||
@@ -742,6 +820,18 @@ 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
|
||||
@@ -779,10 +869,17 @@ int main(int argc, char **argv) {
|
||||
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
|
||||
g_print("[main] Favicon database enabled\n");
|
||||
|
||||
/* Initialize browser-managed/attached network services after login and
|
||||
* after the shared WebKit context/data manager have been configured. */
|
||||
net_services_init();
|
||||
|
||||
/* 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);
|
||||
|
||||
/* Register nostr:// entity pages before creating any webviews. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
|
||||
/* Vertical box: the tab manager's notebook fills the window. */
|
||||
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_container_add(GTK_CONTAINER(window), vbox);
|
||||
@@ -830,6 +927,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;
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
/*
|
||||
* 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 <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;
|
||||
net_service_t *tor = &g_services[NET_SERVICE_TOR];
|
||||
WebKitWebContext *ctx = webkit_web_context_get_default();
|
||||
WebKitWebsiteDataManager *dm = webkit_web_context_get_website_data_manager(ctx);
|
||||
if (!net_service_is_enabled(NET_SERVICE_TOR)) {
|
||||
webkit_website_data_manager_set_network_proxy_settings(
|
||||
dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL);
|
||||
g_print("[net.service.tor] WebKit proxy disabled\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const char *uri = "socks5://127.0.0.1:1";
|
||||
if (tor->state == SERVICE_READY && tor->status.tor.socks_endpoint[0])
|
||||
uri = tor->status.tor.socks_endpoint;
|
||||
const gchar *ignore_with_fips[] = { "*.fips", NULL };
|
||||
const gchar *ignore_empty[] = { NULL };
|
||||
const gchar * const *ignore_hosts = net_service_is_ready(NET_SERVICE_FIPS)
|
||||
? ignore_with_fips : ignore_empty;
|
||||
WebKitNetworkProxySettings *proxy =
|
||||
webkit_network_proxy_settings_new(uri, ignore_hosts);
|
||||
webkit_website_data_manager_set_network_proxy_settings(
|
||||
dm, WEBKIT_NETWORK_PROXY_MODE_CUSTOM, proxy);
|
||||
webkit_network_proxy_settings_free(proxy);
|
||||
g_print("[net.service.tor] WebKit proxy %s: %s%s\n",
|
||||
tor->state == SERVICE_READY ? "ready" : "fail-closed", uri,
|
||||
net_service_is_ready(NET_SERVICE_FIPS) ? " (bypass *.fips)" : "");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 */
|
||||
+1792
-9
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
+75
-4
@@ -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
|
||||
|
||||
+191
-1
@@ -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 = FALSE;
|
||||
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 = FALSE;
|
||||
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;
|
||||
|
||||
/*
|
||||
|
||||
+545
-67
@@ -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);
|
||||
|
||||
+17
-7
@@ -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"
|
||||
@@ -87,6 +91,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,
|
||||
@@ -43,6 +44,10 @@ typedef enum {
|
||||
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;
|
||||
|
||||
|
||||
+688
-15
@@ -10,12 +10,15 @@
|
||||
#include "tab_manager.h"
|
||||
#include "settings.h"
|
||||
#include "nostr_inject.h"
|
||||
#include "nostr_url.h"
|
||||
#include "history.h"
|
||||
#include "bookmarks.h"
|
||||
#include "search.h"
|
||||
#include "version.h"
|
||||
#include "embedded_web_content.h"
|
||||
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
|
||||
#include "db.h"
|
||||
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
|
||||
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
@@ -49,12 +52,29 @@ extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
|
||||
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
|
||||
|
||||
/* Key press handler defined in main.c — connected to each webview so
|
||||
* keyboard shortcuts are caught before WebKit consumes the event. */
|
||||
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
gpointer data);
|
||||
|
||||
/* ── Per-window sidebar state ─────────────────────────────────────── *
|
||||
* The agent chat sidebar is per-window (not per-tab). Each window has
|
||||
* a window-level GtkPaned (sidebar | notebook). The sidebar webview is
|
||||
* lazily created on first toggle. All windows' sidebars show the same
|
||||
* global chat session (agent_chat_store's g_current_session_id).
|
||||
* Visibility is independent per-window. */
|
||||
|
||||
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;
|
||||
|
||||
/* ── Static state ─────────────────────────────────────────────────── */
|
||||
|
||||
static GtkWidget *g_notebook = NULL;
|
||||
@@ -67,6 +87,22 @@ static GtkWindow *g_window = NULL;
|
||||
static GtkWindow *g_active_window = NULL;
|
||||
static GtkWidget *g_active_notebook = NULL;
|
||||
|
||||
/* ── Per-window sidebar state ─────────────────────────────────────── *
|
||||
* The agent chat sidebar is now per-window (not per-tab). Each window
|
||||
* has a window-level GtkPaned (sidebar | notebook). The sidebar
|
||||
* webview is lazily created on first toggle. All windows' sidebars
|
||||
* show the same global chat session (agent_chat_store's
|
||||
* g_current_session_id). Visibility is independent per-window.
|
||||
*
|
||||
* The window_state_t struct and helper forward declarations are above
|
||||
* (in the forward declarations section) because on_window_focus_in /
|
||||
* on_aux_window_destroy — which are defined before the sidebar section —
|
||||
* need them. */
|
||||
|
||||
static window_state_t g_main_window = {0};
|
||||
static GArray *g_aux_windows = NULL; /* GArray of window_state_t */
|
||||
static window_state_t *g_active_ws = NULL; /* points to g_main_window or an aux entry */
|
||||
|
||||
/* Target notebook for the next tab_create() call.
|
||||
*
|
||||
* When non-NULL, tab_create()/tab_manager_new_tab() add the new tab to
|
||||
@@ -93,6 +129,13 @@ static int g_tab_cap = 0;
|
||||
|
||||
/* ── Forward declarations ─────────────────────────────────────────── */
|
||||
|
||||
/* Per-window sidebar helpers (defined in the sidebar section at the
|
||||
* bottom of this file, but needed by on_window_focus_in /
|
||||
* on_aux_window_destroy which are defined earlier). The window_state_t
|
||||
* typedef is above, before the static state section. */
|
||||
static window_state_t *get_active_window_state(void);
|
||||
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
|
||||
|
||||
static tab_info_t *tab_create(const char *url);
|
||||
static GtkWidget *build_tab_label(tab_info_t *tab);
|
||||
static GtkWidget *tab_find_notebook(GtkWidget *page);
|
||||
@@ -104,6 +147,9 @@ static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer d
|
||||
static void on_avatar_clicked(GtkButton *btn, gpointer data);
|
||||
static GtkWidget *tab_manager_new_window(const char *url,
|
||||
WebKitWebView *related_view);
|
||||
static gboolean on_notebook_button_press(GtkWidget *widget,
|
||||
GdkEventButton *event,
|
||||
gpointer user_data);
|
||||
static gboolean on_window_focus_in(GtkWidget *widget,
|
||||
GdkEventFocus *event,
|
||||
gpointer user_data);
|
||||
@@ -164,10 +210,48 @@ static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
* search engine.
|
||||
*/
|
||||
|
||||
/* Convert fips://host[:port]/path?query#fragment into normal HTTP while
|
||||
* inserting the mesh DNS suffix after the authority's host component. */
|
||||
static char *normalize_fips_url(const char *input) {
|
||||
if (input == NULL || strncmp(input, "fips://", 7) != 0) return NULL;
|
||||
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 = NULL;
|
||||
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;
|
||||
}
|
||||
|
||||
if (strncmp(input, "fips://", 7) == 0)
|
||||
return normalize_fips_url(input);
|
||||
|
||||
/* Route bare NIP-19 entities and NIP-21 links through nostr://. Private
|
||||
* keys are deliberately not normalized into navigable URLs. */
|
||||
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) {
|
||||
@@ -521,6 +605,117 @@ static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Notebook-level button-press handler. Catches right-clicks on tab
|
||||
* labels that GtkNotebook's internal handling would otherwise consume
|
||||
* before the tab label's own button-press-event handler fires. We find
|
||||
* which tab was clicked by checking the tab label's allocation against
|
||||
* the click coordinates, then show the same context menu as
|
||||
* on_tab_label_button_press. */
|
||||
static gboolean on_notebook_button_press(GtkWidget *widget,
|
||||
GdkEventButton *event,
|
||||
gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (widget == NULL) return FALSE;
|
||||
GtkNotebook *nb = GTK_NOTEBOOK(widget);
|
||||
if (event->button != 3 && event->button != 2) return FALSE;
|
||||
|
||||
gint n_pages = gtk_notebook_get_n_pages(nb);
|
||||
for (gint i = 0; i < n_pages; i++) {
|
||||
GtkWidget *page = gtk_notebook_get_nth_page(nb, i);
|
||||
if (page == NULL) continue;
|
||||
GtkWidget *label = gtk_notebook_get_tab_label(nb, page);
|
||||
if (label == NULL) continue;
|
||||
|
||||
GtkAllocation alloc;
|
||||
gtk_widget_get_allocation(label, &alloc);
|
||||
|
||||
/* Convert click coords to the label's coordinate space. The
|
||||
* event coords are relative to the notebook's bin window. */
|
||||
gint x = (gint)event->x;
|
||||
gint y = (gint)event->y;
|
||||
|
||||
/* Get the label's allocation position relative to the notebook. */
|
||||
gint lx = alloc.x;
|
||||
gint ly = alloc.y;
|
||||
|
||||
if (x >= lx && x < lx + alloc.width &&
|
||||
y >= ly && y < ly + alloc.height) {
|
||||
/* Found the clicked tab. Look up the tab_info_t. */
|
||||
if (i < g_tab_count && g_tabs[i] != NULL) {
|
||||
/* Reuse the existing handler by synthesizing a call. */
|
||||
tab_info_t *tab = g_tabs[i];
|
||||
int index = tab_array_find(tab);
|
||||
if (index < 0) return FALSE;
|
||||
|
||||
if (event->button == 2) {
|
||||
const browser_settings_t *s = settings_get();
|
||||
if (s->middle_click_close) {
|
||||
tab_manager_close_tab(index);
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Right-click: show context menu (same as
|
||||
* on_tab_label_button_press). */
|
||||
GtkWidget *menu = gtk_menu_new();
|
||||
|
||||
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
|
||||
g_signal_connect(item_new, "activate",
|
||||
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
|
||||
|
||||
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
|
||||
g_signal_connect_swapped(item_close, "activate",
|
||||
G_CALLBACK(tab_manager_close_tab),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
|
||||
|
||||
GtkWidget *item_close_others =
|
||||
gtk_menu_item_new_with_label("Close Other Tabs");
|
||||
g_signal_connect_swapped(item_close_others, "activate",
|
||||
G_CALLBACK(tab_manager_close_others),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
|
||||
|
||||
GtkWidget *item_close_right =
|
||||
gtk_menu_item_new_with_label("Close Tabs to the Right");
|
||||
g_signal_connect_swapped(item_close_right, "activate",
|
||||
G_CALLBACK(tab_manager_close_to_right),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_new_win =
|
||||
gtk_menu_item_new_with_label("Open in New Window");
|
||||
g_signal_connect_swapped(item_new_win, "activate",
|
||||
G_CALLBACK(tab_manager_open_in_new_window),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win);
|
||||
|
||||
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
|
||||
g_signal_connect_swapped(item_dup, "activate",
|
||||
G_CALLBACK(tab_manager_duplicate),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
|
||||
|
||||
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
|
||||
g_signal_connect_swapped(item_reload, "activate",
|
||||
G_CALLBACK(tab_manager_reload),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||||
|
||||
gtk_widget_show_all(menu);
|
||||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static gboolean on_tab_label_button_press(GtkWidget *widget,
|
||||
GdkEventButton *event,
|
||||
gpointer user_data) {
|
||||
@@ -571,6 +766,13 @@ static gboolean on_tab_label_button_press(GtkWidget *widget,
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_new_win =
|
||||
gtk_menu_item_new_with_label("Open in New Window");
|
||||
g_signal_connect_swapped(item_new_win, "activate",
|
||||
G_CALLBACK(tab_manager_open_in_new_window),
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win);
|
||||
|
||||
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
|
||||
g_signal_connect_swapped(item_dup, "activate",
|
||||
G_CALLBACK(tab_manager_duplicate),
|
||||
@@ -649,7 +851,6 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
WebKitPolicyDecision *decision,
|
||||
WebKitPolicyDecisionType type,
|
||||
gpointer user_data) {
|
||||
(void)webview;
|
||||
(void)user_data;
|
||||
|
||||
if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
|
||||
@@ -662,6 +863,44 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
if (action == NULL)
|
||||
return FALSE;
|
||||
|
||||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||||
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
|
||||
|
||||
/* fips:// is a shorthand rather than a custom WebKit URI scheme. This
|
||||
* catches page links in addition to URL-bar normalization. */
|
||||
if (uri && strncmp(uri, "fips://", 7) == 0) {
|
||||
char *normalized = normalize_fips_url(uri);
|
||||
if (normalized) {
|
||||
webkit_web_view_load_uri(webview, normalized);
|
||||
g_free(normalized);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/* NIP-21 links use nostr:entity rather than nostr://entity. Normalize
|
||||
* either form so WebKit consistently invokes our registered handler. */
|
||||
if (uri && strncmp(uri, "nostr:", 6) == 0 &&
|
||||
strncmp(uri, "nostr://", 8) != 0) {
|
||||
nostr_entity_type_t entity_type = nostr_url_detect(uri);
|
||||
if (entity_type == NOSTR_ENTITY_NSEC) {
|
||||
webkit_web_view_load_html(webview,
|
||||
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
|
||||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
|
||||
"nostr://blocked-private-key");
|
||||
webkit_policy_decision_ignore(decision);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
char *normalized = nostr_url_normalize(uri);
|
||||
if (normalized) {
|
||||
webkit_web_view_load_uri(webview, normalized);
|
||||
g_free(normalized);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only intercept link clicks, not form submissions or reloads. */
|
||||
WebKitNavigationType nav_type =
|
||||
webkit_navigation_action_get_navigation_type(action);
|
||||
@@ -677,8 +916,6 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
|
||||
/* Middle-click or Ctrl+click opens a new tab. */
|
||||
if (button == 2 || (mods & GDK_CONTROL_MASK)) {
|
||||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||||
const char *uri = webkit_uri_request_get_uri(request);
|
||||
if (uri && uri[0]) {
|
||||
tab_manager_new_tab(uri);
|
||||
}
|
||||
@@ -760,6 +997,8 @@ static gboolean on_window_focus_in(GtkWidget *widget,
|
||||
|
||||
g_active_window = GTK_WINDOW(widget);
|
||||
g_active_notebook = notebook;
|
||||
g_active_ws = window_state_for_notebook(notebook);
|
||||
if (g_active_ws == NULL) g_active_ws = &g_main_window;
|
||||
g_print("[windows] Active window changed to %p (notebook %p)\n",
|
||||
(void *)g_active_window, (void *)g_active_notebook);
|
||||
return FALSE;
|
||||
@@ -767,14 +1006,27 @@ static gboolean on_window_focus_in(GtkWidget *widget,
|
||||
|
||||
/* destroy handler for auxiliary windows: if this was the active window,
|
||||
* fall back to the main window/notebook so MCP keeps working. Does NOT
|
||||
* quit the app — only the main window's destroy handler does that. */
|
||||
* quit the app — only the main window's destroy handler does that.
|
||||
* Also removes the window's window_state_t from g_aux_windows. */
|
||||
static void on_aux_window_destroy(GtkWidget *widget, gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (g_active_window == GTK_WINDOW(widget)) {
|
||||
g_active_window = g_window;
|
||||
g_active_notebook = g_notebook;
|
||||
g_active_ws = &g_main_window;
|
||||
g_print("[windows] Active window closed, reverting to main window\n");
|
||||
}
|
||||
/* Remove the destroyed window's entry from g_aux_windows. */
|
||||
if (g_aux_windows != NULL) {
|
||||
for (guint i = 0; i < g_aux_windows->len; i++) {
|
||||
window_state_t *ws = &g_array_index(g_aux_windows,
|
||||
window_state_t, i);
|
||||
if (ws->window == GTK_WINDOW(widget)) {
|
||||
g_array_remove_index_fast(g_aux_windows, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_print("[windows] Auxiliary window destroyed: %p\n", (void *)widget);
|
||||
}
|
||||
|
||||
@@ -795,7 +1047,22 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook), FALSE);
|
||||
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
|
||||
gtk_container_add(GTK_CONTAINER(window), notebook);
|
||||
|
||||
/* Build a window-level GtkPaned: left = sidebar container, right =
|
||||
* notebook. The sidebar is per-window (not per-tab), so it persists
|
||||
* across tab switches within this window. Hidden by default. */
|
||||
GtkWidget *paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_widget_set_vexpand(paned, TRUE);
|
||||
gtk_widget_set_hexpand(paned, TRUE);
|
||||
|
||||
GtkWidget *sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_paned_pack1(GTK_PANED(paned), sidebar_container, FALSE, FALSE);
|
||||
gtk_widget_hide(sidebar_container);
|
||||
|
||||
gtk_paned_pack2(GTK_PANED(paned), notebook, TRUE, TRUE);
|
||||
gtk_paned_set_position(GTK_PANED(paned), 0);
|
||||
|
||||
gtk_container_add(GTK_CONTAINER(window), paned);
|
||||
|
||||
/* Build a full tab via tab_create(). Set g_target_notebook so the
|
||||
* tab is added to this window's notebook (not the main notebook),
|
||||
@@ -840,6 +1107,7 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
/* Show the tab widgets before switching to it. */
|
||||
gtk_widget_show_all(tab->page);
|
||||
gtk_widget_show_all(tab->tab_label);
|
||||
|
||||
gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num);
|
||||
|
||||
/* Window lifecycle: focus-in updates the active window/notebook
|
||||
@@ -851,11 +1119,30 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
|
||||
/* Show everything and present the window. */
|
||||
gtk_widget_show_all(window);
|
||||
/* Re-hide the sidebar container after show_all — it should only
|
||||
* appear when the user toggles it. (Same rationale as the main
|
||||
* window in tab_manager_init.) */
|
||||
gtk_widget_hide(sidebar_container);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
|
||||
/* Register this window's window_state_t in g_aux_windows. */
|
||||
if (g_aux_windows == NULL) {
|
||||
g_aux_windows = g_array_new(FALSE, FALSE, sizeof(window_state_t));
|
||||
}
|
||||
window_state_t ws = {0};
|
||||
ws.window = GTK_WINDOW(window);
|
||||
ws.notebook = notebook;
|
||||
ws.paned = paned;
|
||||
ws.sidebar_container = sidebar_container;
|
||||
ws.sidebar_webview = NULL;
|
||||
ws.sidebar_visible = FALSE;
|
||||
g_array_append_val(g_aux_windows, ws);
|
||||
|
||||
/* This new window is now the active window. */
|
||||
g_active_window = GTK_WINDOW(window);
|
||||
g_active_notebook = notebook;
|
||||
g_active_ws = &g_array_index(g_aux_windows, window_state_t,
|
||||
g_aux_windows->len - 1);
|
||||
|
||||
g_print("[windows] Created new window %p (tab %d) for %s\n",
|
||||
(void *)window, index, url ? url : "(none)");
|
||||
@@ -896,13 +1183,33 @@ static GtkWidget *build_tab_label(tab_info_t *tab) {
|
||||
gtk_widget_set_tooltip_text(close_btn, "Close tab");
|
||||
g_signal_connect(close_btn, "clicked",
|
||||
G_CALLBACK(on_tab_close_clicked), tab);
|
||||
/* Connect button-press so right-click on the close button
|
||||
* also triggers the tab context menu. */
|
||||
gtk_widget_add_events(close_btn, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(close_btn, "button-press-event",
|
||||
G_CALLBACK(on_tab_label_button_press), tab);
|
||||
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
|
||||
}
|
||||
|
||||
/* Connect button-press for middle-click and right-click on the label. */
|
||||
/* Connect button-press for middle-click and right-click on the label.
|
||||
* We add the event mask and connect the handler to the box AND all
|
||||
* child widgets (favicon, label, close button), because child widgets
|
||||
* intercept button-press events before they bubble up to the parent.
|
||||
* Without this, right-clicking on the label or close button wouldn't
|
||||
* show the context menu. */
|
||||
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(box, "button-press-event",
|
||||
G_CALLBACK(on_tab_label_button_press), tab);
|
||||
/* Also connect child widgets so right-clicks on them trigger the
|
||||
* context menu. The handler checks event->button and only acts on
|
||||
* right-click (button 3) and middle-click (button 2), so normal
|
||||
* left-clicks on the close button still work. */
|
||||
gtk_widget_add_events(tab->favicon, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(tab->favicon, "button-press-event",
|
||||
G_CALLBACK(on_tab_label_button_press), tab);
|
||||
gtk_widget_add_events(tab->title_label, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(tab->title_label, "button-press-event",
|
||||
G_CALLBACK(on_tab_label_button_press), tab);
|
||||
|
||||
gtk_widget_show_all(box);
|
||||
return box;
|
||||
@@ -913,6 +1220,27 @@ static GtkWidget *build_tab_label(tab_info_t *tab) {
|
||||
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
|
||||
tab_info_t *tab = (tab_info_t *)user_data;
|
||||
const char *text = gtk_entry_get_text(entry);
|
||||
|
||||
/* Agent chat shortcut: "; <message>" routes to the embedded agent. */
|
||||
if (text[0] == ';') {
|
||||
const char *msg = text + 1;
|
||||
/* Skip leading whitespace after the semicolon. */
|
||||
while (*msg == ' ' || *msg == '\t') msg++;
|
||||
agent_chat_route_input(*msg ? msg : NULL);
|
||||
/* Clear the URL bar after sending to agent. */
|
||||
gtk_entry_set_text(entry, "");
|
||||
return;
|
||||
}
|
||||
|
||||
nostr_entity_type_t entity_type = nostr_url_detect(text);
|
||||
if (entity_type == NOSTR_ENTITY_NSEC) {
|
||||
webkit_web_view_load_html(tab->webview,
|
||||
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
|
||||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
|
||||
"nostr://blocked-private-key");
|
||||
return;
|
||||
}
|
||||
|
||||
char *url = normalize_url(text);
|
||||
if (url != NULL) {
|
||||
webkit_web_view_load_uri(tab->webview, url);
|
||||
@@ -1060,11 +1388,15 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
} else if (load_event == WEBKIT_LOAD_FINISHED) {
|
||||
const gchar *title = webkit_web_view_get_title(webview);
|
||||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||||
g_print("[loaded] %s -- title: %s\n",
|
||||
uri ? uri : "(null)",
|
||||
(title && title[0]) ? title : "(none)");
|
||||
/* The title may not be available yet at LOAD_FINISHED — it often
|
||||
* arrives slightly later via the notify::title signal (handled by
|
||||
* on_title_changed). Log what we have; the title handler will log
|
||||
* the real title when it arrives. */
|
||||
g_print("[loaded] %s\n", uri ? uri : "(null)");
|
||||
|
||||
/* Update tab title (works for tabs in any window's notebook). */
|
||||
/* Update tab title if already available (works for tabs in any
|
||||
* window's notebook). The notify::title handler covers the case
|
||||
* where the title arrives after load-finished. */
|
||||
if (tab != NULL && title && title[0]) {
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
@@ -1073,7 +1405,10 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
}
|
||||
|
||||
/* Add to history (with title for the Recents submenu tooltip).
|
||||
* This applies to both main-window tabs and auxiliary windows. */
|
||||
* This applies to both main-window tabs and auxiliary windows.
|
||||
* If the title isn't available yet, history_add_titled will use
|
||||
* the URL; the notify::title handler could update it later if
|
||||
* needed. */
|
||||
if (uri != NULL && uri[0] != '\0') {
|
||||
history_add_titled(uri, (title && title[0]) ? title : NULL);
|
||||
}
|
||||
@@ -1088,13 +1423,57 @@ static gboolean on_load_failed(WebKitWebView *webview,
|
||||
gpointer data) {
|
||||
(void)webview;
|
||||
(void)load_event;
|
||||
(void)data;
|
||||
tab_info_t *tab = (tab_info_t *)data;
|
||||
g_print("[failed] %s -- %s\n",
|
||||
failing_uri ? failing_uri : "(null)",
|
||||
error ? error->message : "(unknown)");
|
||||
|
||||
/* Keep the failed URL in the address bar so the user can see what
|
||||
* failed and edit/retry. Without this, WebKit reverts to about:blank
|
||||
* and the user loses the URL they typed or clicked. */
|
||||
if (tab != NULL && failing_uri != NULL && failing_uri[0] != '\0') {
|
||||
if (strcmp(failing_uri, "about:blank") != 0) {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), failing_uri);
|
||||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", failing_uri);
|
||||
}
|
||||
|
||||
/* Set a descriptive tab title from the error so the user sees
|
||||
* something meaningful in the tab bar instead of "Loading…". */
|
||||
const char *msg = (error && error->message) ? error->message : "Load failed";
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
tab_manager_set_title(index, msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Called when the webview's "title" property changes. The title often
|
||||
* arrives slightly after WEBKIT_LOAD_FINISHED, so the load-finished
|
||||
* handler may log "(none)". This handler picks up the real title as
|
||||
* soon as it's available and updates the tab title + logs it. */
|
||||
static void on_title_changed(WebKitWebView *webview,
|
||||
GParamSpec *pspec,
|
||||
gpointer user_data) {
|
||||
(void)pspec;
|
||||
tab_info_t *tab = (tab_info_t *)user_data;
|
||||
const gchar *title = webkit_web_view_get_title(webview);
|
||||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||||
|
||||
if (title && title[0]) {
|
||||
g_print("[title] %s -- %s\n",
|
||||
uri ? uri : "(null)", title);
|
||||
|
||||
if (tab != NULL) {
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
tab_manager_set_title(index, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||||
|
||||
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
|
||||
@@ -1282,12 +1661,48 @@ void tab_manager_toggle_inspector(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Zoom control ──────────────────────────────────────────────────── */
|
||||
|
||||
#define ZOOM_MIN 0.25
|
||||
#define ZOOM_MAX 5.0
|
||||
#define ZOOM_STEP 1.1
|
||||
|
||||
void tab_manager_zoom_in(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (!tab || !tab->webview) return;
|
||||
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
|
||||
z *= ZOOM_STEP;
|
||||
if (z > ZOOM_MAX) z = ZOOM_MAX;
|
||||
webkit_web_view_set_zoom_level(tab->webview, z);
|
||||
}
|
||||
|
||||
void tab_manager_zoom_out(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (!tab || !tab->webview) return;
|
||||
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
|
||||
z /= ZOOM_STEP;
|
||||
if (z < ZOOM_MIN) z = ZOOM_MIN;
|
||||
webkit_web_view_set_zoom_level(tab->webview, z);
|
||||
}
|
||||
|
||||
void tab_manager_zoom_reset(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (!tab || !tab->webview) return;
|
||||
webkit_web_view_set_zoom_level(tab->webview, 1.0);
|
||||
}
|
||||
|
||||
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_toggle_inspector();
|
||||
}
|
||||
|
||||
static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_toggle_sidebar();
|
||||
}
|
||||
|
||||
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
@@ -1686,11 +2101,21 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
|
||||
G_CALLBACK(on_menu_inspector), NULL);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
|
||||
|
||||
GtkWidget *item_sidebar = gtk_menu_item_new_with_label("Toggle Agent Sidebar");
|
||||
g_signal_connect(item_sidebar, "activate",
|
||||
G_CALLBACK(on_menu_toggle_sidebar), NULL);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_sidebar);
|
||||
|
||||
GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile");
|
||||
g_signal_connect(item_profile, "activate",
|
||||
G_CALLBACK(on_menu_profile), g_window);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile);
|
||||
|
||||
GtkWidget *item_agent = gtk_menu_item_new_with_label("Agent Setup…");
|
||||
g_signal_connect(item_agent, "activate",
|
||||
G_CALLBACK(on_menu_agent), g_window);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent);
|
||||
|
||||
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
|
||||
g_signal_connect(item_settings, "activate",
|
||||
G_CALLBACK(on_menu_settings), g_window);
|
||||
@@ -1731,7 +2156,8 @@ static int tab_array_add(tab_info_t *tab) {
|
||||
|
||||
static void tab_array_remove(int index) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
/* Free the tab_info_t. */
|
||||
/* Free the tab_info_t. Signal handlers are already disconnected by
|
||||
* tab_manager_close_tab() before the webview is destroyed. */
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
if (tab) {
|
||||
/* The webview and widgets are destroyed by GtkNotebook when the
|
||||
@@ -1841,6 +2267,31 @@ static tab_info_t *tab_create(const char *url) {
|
||||
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
|
||||
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
|
||||
|
||||
/* Enhance native application/json documents at document end. The
|
||||
* embedded file is not NUL-terminated, so copy it before passing it to
|
||||
* WebKitUserScript. */
|
||||
if (s->json_viewer_enabled) {
|
||||
const embedded_file_t *json_viewer =
|
||||
get_embedded_file("json-viewer/json-viewer.js");
|
||||
if (json_viewer != NULL) {
|
||||
char *source = g_strndup((const char *)json_viewer->data,
|
||||
json_viewer->size);
|
||||
WebKitUserContentManager *manager =
|
||||
webkit_web_view_get_user_content_manager(tab->webview);
|
||||
WebKitUserScript *script = webkit_user_script_new(
|
||||
source,
|
||||
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END,
|
||||
NULL,
|
||||
NULL);
|
||||
webkit_user_content_manager_add_script(manager, script);
|
||||
webkit_user_script_unref(script);
|
||||
g_free(source);
|
||||
} else {
|
||||
g_printerr("[json-viewer] Embedded script not found\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* Inject window.nostr into this webview. */
|
||||
nostr_inject_setup(tab->webview);
|
||||
|
||||
@@ -1965,6 +2416,9 @@ static tab_info_t *tab_create(const char *url) {
|
||||
* display servers, resulting in a blank page. */
|
||||
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
|
||||
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
|
||||
|
||||
/* The sidebar is now at the window level (window_state_t.paned),
|
||||
* not per-tab. The webview is packed directly into the tab's page. */
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
|
||||
TRUE, TRUE, 0);
|
||||
|
||||
@@ -1983,9 +2437,11 @@ static tab_info_t *tab_create(const char *url) {
|
||||
g_signal_connect(tab->webview, "load-changed",
|
||||
G_CALLBACK(on_load_changed), tab);
|
||||
g_signal_connect(tab->webview, "load-failed",
|
||||
G_CALLBACK(on_load_failed), NULL);
|
||||
G_CALLBACK(on_load_failed), tab);
|
||||
g_signal_connect(tab->webview, "notify::favicon",
|
||||
G_CALLBACK(on_favicon_changed), tab);
|
||||
g_signal_connect(tab->webview, "notify::title",
|
||||
G_CALLBACK(on_title_changed), tab);
|
||||
g_signal_connect(tab->webview, "context-menu",
|
||||
G_CALLBACK(on_webview_context_menu), tab);
|
||||
g_signal_connect(tab->webview, "decide-policy",
|
||||
@@ -2220,6 +2676,15 @@ void tab_manager_init(GtkContainer *parent,
|
||||
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE);
|
||||
|
||||
/* Connect button-press on the notebook to handle right-click context
|
||||
* menus on tabs. This is more reliable than connecting to individual
|
||||
* tab label child widgets, because GtkNotebook intercepts button
|
||||
* presses on tabs for tab switching before they reach the label's
|
||||
* children. */
|
||||
gtk_widget_add_events(g_notebook, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(g_notebook, "button-press-event",
|
||||
G_CALLBACK(on_notebook_button_press), NULL);
|
||||
|
||||
/* New-tab button as an action widget at the end of the tab strip. */
|
||||
GtkWidget *new_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
|
||||
@@ -2276,13 +2741,36 @@ void tab_manager_init(GtkContainer *parent,
|
||||
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
g_object_unref(css);
|
||||
|
||||
gtk_container_add(parent, g_notebook);
|
||||
/* Build a window-level GtkPaned: left = sidebar container, right =
|
||||
* notebook. The sidebar is per-window (not per-tab), so it persists
|
||||
* across tab switches. Hidden by default — shown when the user
|
||||
* toggles it via Ctrl+Shift+A, the menu item, or the ";" shortcut. */
|
||||
g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_widget_set_vexpand(g_main_window.paned, TRUE);
|
||||
gtk_widget_set_hexpand(g_main_window.paned, TRUE);
|
||||
|
||||
g_main_window.sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_paned_pack1(GTK_PANED(g_main_window.paned),
|
||||
g_main_window.sidebar_container, FALSE, FALSE);
|
||||
gtk_widget_hide(g_main_window.sidebar_container);
|
||||
|
||||
gtk_paned_pack2(GTK_PANED(g_main_window.paned), g_notebook, TRUE, TRUE);
|
||||
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
|
||||
|
||||
gtk_container_add(parent, g_main_window.paned);
|
||||
|
||||
/* Populate the main window's window_state_t. */
|
||||
g_main_window.window = window;
|
||||
g_main_window.notebook = g_notebook;
|
||||
g_main_window.sidebar_webview = NULL;
|
||||
g_main_window.sidebar_visible = FALSE;
|
||||
|
||||
/* The main window/notebook are the default active window/notebook
|
||||
* for MCP get_active_webview(). Updated when auxiliary windows
|
||||
* gain focus (see on_window_focus_in). */
|
||||
g_active_window = window;
|
||||
g_active_notebook = g_notebook;
|
||||
g_active_ws = &g_main_window;
|
||||
|
||||
tab_manager_apply_settings();
|
||||
}
|
||||
@@ -2354,6 +2842,16 @@ void tab_manager_close_tab(int index) {
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
if (tab == NULL) return;
|
||||
|
||||
/* Disconnect all signal handlers from the webview BEFORE destroying
|
||||
* it. During gtk_notebook_remove_page(), the webview is destroyed,
|
||||
* which can emit signals (notify::title, load-changed, etc.) that
|
||||
* would fire our handlers with the tab pointer as user_data. If we
|
||||
* disconnect first, those signals are safely ignored. */
|
||||
if (tab->webview) {
|
||||
g_signal_handlers_disconnect_matched(tab->webview,
|
||||
G_SIGNAL_MATCH_DATA, 0, 0, NULL, NULL, tab);
|
||||
}
|
||||
|
||||
/* Find the notebook this tab belongs to — it may be the main
|
||||
* window's notebook or an auxiliary window's notebook. */
|
||||
GtkWidget *nb = tab_find_notebook(tab->page);
|
||||
@@ -2511,6 +3009,23 @@ void tab_manager_duplicate(int index) {
|
||||
tab_manager_new_tab(tab->current_url);
|
||||
}
|
||||
|
||||
void tab_manager_open_in_new_window(int index) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
if (tab == NULL) return;
|
||||
/* Open the tab's current URL in a new window. Pass the active
|
||||
* webview as the related view so the new window shares the
|
||||
* WebProcess. */
|
||||
tab_manager_new_window(tab->current_url, tab->webview);
|
||||
}
|
||||
|
||||
void tab_manager_new_window_blank(void) {
|
||||
/* Open a new window with the default new-tab URL. Pass the active
|
||||
* webview as the related view for WebProcess sharing. */
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
tab_manager_new_window(NULL, tab ? tab->webview : NULL);
|
||||
}
|
||||
|
||||
void tab_manager_reload(int index) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
@@ -2518,3 +3033,161 @@ void tab_manager_reload(int index) {
|
||||
webkit_web_view_reload_bypass_cache(tab->webview);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Agent chat sidebar (per-window) ─────────────────────────────── */
|
||||
|
||||
#define SIDEBAR_DEFAULT_WIDTH 280
|
||||
#define AGENT_CHAT_URL_STR "sovereign://agents/chat"
|
||||
|
||||
/* Return the window_state_t for the currently focused window.
|
||||
* Falls back to the main window if g_active_ws is NULL. */
|
||||
static window_state_t *get_active_window_state(void) {
|
||||
if (g_active_ws != NULL) return g_active_ws;
|
||||
return &g_main_window;
|
||||
}
|
||||
|
||||
/* Return the window_state_t whose notebook matches the given widget.
|
||||
* Checks the main window first, then auxiliary windows. Returns NULL
|
||||
* if no match. */
|
||||
static window_state_t *window_state_for_notebook(GtkWidget *notebook) {
|
||||
if (notebook == NULL) return NULL;
|
||||
if (g_main_window.notebook == notebook) return &g_main_window;
|
||||
if (g_aux_windows != NULL) {
|
||||
for (guint i = 0; i < g_aux_windows->len; i++) {
|
||||
window_state_t *ws = &g_array_index(g_aux_windows,
|
||||
window_state_t, i);
|
||||
if (ws->notebook == notebook) return ws;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Lazily create the sidebar webview for a window. The webview shares
|
||||
* the same WebKitWebContext as the main webview so the sovereign://
|
||||
* scheme works. It is packed into the sidebar container (the left pane
|
||||
* of the window-level GtkPaned). Called the first time the sidebar is
|
||||
* shown for a window. */
|
||||
|
||||
/* Close button callback for the sidebar floating X button. */
|
||||
static void on_sidebar_close_clicked(GtkButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
window_state_t *ws = (window_state_t *)user_data;
|
||||
if (ws == NULL) return;
|
||||
if (ws->sidebar_visible) {
|
||||
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
|
||||
gtk_widget_hide(ws->sidebar_container);
|
||||
ws->sidebar_visible = FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
static void sidebar_create_webview(window_state_t *ws) {
|
||||
if (ws == NULL || ws->sidebar_webview != NULL) return;
|
||||
if (g_ctx == NULL) return;
|
||||
|
||||
ws->sidebar_webview = WEBKIT_WEB_VIEW(
|
||||
webkit_web_view_new_with_context(g_ctx));
|
||||
|
||||
/* Match the main webview's settings so JS and the sovereign://
|
||||
* bridge work. */
|
||||
WebKitSettings *st = webkit_web_view_get_settings(ws->sidebar_webview);
|
||||
webkit_settings_set_enable_developer_extras(st, TRUE);
|
||||
webkit_settings_set_enable_javascript(st, TRUE);
|
||||
webkit_settings_set_allow_file_access_from_file_urls(st, TRUE);
|
||||
webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE);
|
||||
|
||||
/* Inject window.nostr so the chat page's NIP-07 shim works. */
|
||||
nostr_inject_setup(ws->sidebar_webview);
|
||||
|
||||
/* The sidebar webview should NOT create new windows/tabs — links
|
||||
* in the chat page should navigate within the sidebar, not open
|
||||
* new browser tabs. We let the default navigation happen inside
|
||||
* the sidebar webview. */
|
||||
|
||||
gtk_widget_set_vexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
|
||||
gtk_widget_set_hexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
|
||||
|
||||
/* Use a GtkOverlay to float a close (X) button in the top-right
|
||||
* corner over the webview, without a separate header bar. This
|
||||
* saves vertical space — the X sits on the same row as the chat
|
||||
* page's tab bar (Chat / Conversations / Skills). */
|
||||
GtkWidget *overlay = gtk_overlay_new();
|
||||
gtk_widget_set_vexpand(overlay, TRUE);
|
||||
gtk_widget_set_hexpand(overlay, TRUE);
|
||||
|
||||
gtk_container_add(GTK_CONTAINER(overlay),
|
||||
GTK_WIDGET(ws->sidebar_webview));
|
||||
|
||||
/* Floating close button — top-right corner. */
|
||||
GtkWidget *close_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_image(GTK_BUTTON(close_btn),
|
||||
gtk_image_new_from_icon_name("window-close-symbolic",
|
||||
GTK_ICON_SIZE_MENU));
|
||||
gtk_widget_set_tooltip_text(close_btn, "Close sidebar");
|
||||
gtk_widget_set_halign(close_btn, GTK_ALIGN_END);
|
||||
gtk_widget_set_valign(close_btn, GTK_ALIGN_START);
|
||||
gtk_widget_set_margin_top(close_btn, 2);
|
||||
gtk_widget_set_margin_end(close_btn, 2);
|
||||
g_signal_connect(close_btn, "clicked",
|
||||
G_CALLBACK(on_sidebar_close_clicked), ws);
|
||||
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), close_btn);
|
||||
|
||||
/* Pack the overlay into the sidebar container. */
|
||||
gtk_box_pack_start(GTK_BOX(ws->sidebar_container),
|
||||
overlay, TRUE, TRUE, 0);
|
||||
|
||||
/* Load the chat page. */
|
||||
webkit_web_view_load_uri(ws->sidebar_webview, AGENT_CHAT_URL_STR);
|
||||
}
|
||||
|
||||
void tab_manager_toggle_sidebar(void) {
|
||||
window_state_t *ws = get_active_window_state();
|
||||
if (ws == NULL || ws->paned == NULL) return;
|
||||
|
||||
if (ws->sidebar_visible) {
|
||||
/* Hide the sidebar. Set position to 0 so the sidebar gets no
|
||||
* space and the divider disappears. Keep the webview alive. */
|
||||
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
|
||||
gtk_widget_hide(ws->sidebar_container);
|
||||
ws->sidebar_visible = FALSE;
|
||||
} else {
|
||||
/* Show the sidebar. Create the webview lazily on first show.
|
||||
* Set position to 280 so the sidebar gets space and the
|
||||
* divider appears (resizable). */
|
||||
if (ws->sidebar_webview == NULL) {
|
||||
sidebar_create_webview(ws);
|
||||
}
|
||||
gtk_paned_set_position(GTK_PANED(ws->paned),
|
||||
SIDEBAR_DEFAULT_WIDTH);
|
||||
gtk_widget_show_all(ws->sidebar_container);
|
||||
ws->sidebar_visible = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
WebKitWebView *tab_manager_get_main_webview(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab == NULL) return NULL;
|
||||
/* Always return the main webview, never the sidebar. The sidebar
|
||||
* is now at the window level (outside the notebook), so
|
||||
* tab_manager_get_active() — which resolves by notebook page —
|
||||
* will never return the sidebar. */
|
||||
return tab->webview;
|
||||
}
|
||||
|
||||
gboolean tab_manager_sidebar_visible(void) {
|
||||
window_state_t *ws = get_active_window_state();
|
||||
if (ws == NULL) return FALSE;
|
||||
return ws->sidebar_visible;
|
||||
}
|
||||
|
||||
void tab_manager_hide_sidebar_after_show_all(void) {
|
||||
/* gtk_widget_show_all() on the main window recursively shows all
|
||||
* children, including the sidebar container. Re-hide it so the
|
||||
* sidebar stays hidden by default — it should only appear when
|
||||
* the user toggles it. */
|
||||
if (g_main_window.sidebar_container && !g_main_window.sidebar_visible) {
|
||||
gtk_widget_hide(g_main_window.sidebar_container);
|
||||
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,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).
|
||||
@@ -160,6 +173,46 @@ 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);
|
||||
|
||||
/*
|
||||
* 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.26"
|
||||
#define SB_VERSION "v0.0.34"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 26
|
||||
#define SB_VERSION_PATCH 34
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#3366cc"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-family="sans-serif" font-size="18" font-weight="bold">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1,5 @@
|
||||
// External script loaded by index.html
|
||||
(function () {
|
||||
var out = document.getElementById('inline-script-out');
|
||||
if (out) out.textContent = 'External script assets/home.js ran successfully @ ' + new Date().toISOString();
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
|
||||
<rect width="120" height="60" rx="8" fill="#3366cc"/>
|
||||
<text x="60" y="38" text-anchor="middle" fill="white" font-family="sans-serif" font-size="20" font-weight="bold">LOCAL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">
|
||||
<rect width="200" height="120" fill="#eee"/>
|
||||
<circle cx="60" cy="60" r="30" fill="#e55"/>
|
||||
<rect x="110" y="30" width="60" height="60" fill="#5a5"/>
|
||||
<line x1="10" y1="100" x2="190" y2="20" stroke="#333" stroke-width="2"/>
|
||||
<text x="100" y="115" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#333">pic.svg</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,18 @@
|
||||
// Shared Worker test — can be shared across multiple tabs/pages
|
||||
var connections = 0;
|
||||
|
||||
self.onconnect = function (e) {
|
||||
var port = e.ports[0];
|
||||
connections++;
|
||||
port.postMessage('SharedWorker connected! Total connections: ' + connections);
|
||||
port.onmessage = function (ev) {
|
||||
var msg = ev.data;
|
||||
if (msg.cmd === 'ping') {
|
||||
port.postMessage('pong from SharedWorker (connections=' + connections + ')');
|
||||
} else if (msg.cmd === 'count') {
|
||||
port.postMessage('SharedWorker has ' + connections + ' active connection(s)');
|
||||
} else {
|
||||
port.postMessage('SharedWorker received: ' + JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Local site shared stylesheet */
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #222;
|
||||
--accent: #36c;
|
||||
--border: #ddd;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
header h1 { margin: 0 0 0.5rem; font-size: 1.4rem; }
|
||||
nav { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
nav a { color: white; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,0.5); }
|
||||
nav a:hover { background: rgba(255,255,255,0.15); }
|
||||
main { max-width: 820px; margin: 1.5rem auto; padding: 0 1.5rem; }
|
||||
h2 { color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
||||
code, pre {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre { padding: 0.75rem; overflow: auto; }
|
||||
figure { margin: 1rem 0; }
|
||||
figcaption { font-size: 0.85rem; color: #666; }
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--accent);
|
||||
background: white;
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
button:hover { background: var(--accent); color: white; }
|
||||
input, textarea, select { font: inherit; padding: 0.3rem; }
|
||||
label { display: inline-block; margin-right: 1rem; }
|
||||
footer { border-top: 1px solid var(--border); margin-top: 2rem; padding: 1rem 1.5rem; color: #666; }
|
||||
iframe { border: 1px solid var(--border); border-radius: 4px; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@@ -0,0 +1,12 @@
|
||||
// Service Worker test — minimal SW that just responds to install/activate
|
||||
self.addEventListener('install', function (event) {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
// Pass-through — don't intercept anything
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// Web Worker test — receives a message, computes, posts result back
|
||||
self.onmessage = function (e) {
|
||||
var msg = e.data;
|
||||
if (msg.cmd === 'compute') {
|
||||
var result = msg.a * msg.b;
|
||||
self.postMessage('Worker computed ' + msg.a + ' * ' + msg.b + ' = ' + result);
|
||||
} else {
|
||||
self.postMessage('Worker received unknown command: ' + msg.cmd);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Contact</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Contact</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="about.html">About</a>
|
||||
<a href="contact.html">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>Contact us via the <a href="form.html">form page</a>.</p>
|
||||
<p>Or go <a href="../local-site/index.html">home via a parent-relative link</a>
|
||||
(this should resolve to the same directory since we are at the root).</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Data (fetch + XHR)</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Data Page</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="data.html">Data</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>fetch() a local JSON file</h2>
|
||||
<pre id="fetch-out">fetch() not started.</pre>
|
||||
|
||||
<h2>XMLHttpRequest a local JSON file</h2>
|
||||
<pre id="xhr-out">XHR not started.</pre>
|
||||
|
||||
<h2>fetch() a file in a subdirectory</h2>
|
||||
<pre id="fetch-sub-out">sub fetch not started.</pre>
|
||||
|
||||
<h2>fetch() a missing file (expect rejection)</h2>
|
||||
<pre id="fetch-missing-out">missing fetch not started.</pre>
|
||||
|
||||
<h2>fetch() with a query string</h2>
|
||||
<pre id="fetch-query-out">query fetch not started.</pre>
|
||||
</main>
|
||||
<script>
|
||||
(async function () {
|
||||
// 1. fetch a sibling JSON file
|
||||
try {
|
||||
const r = await fetch('assets/data.json');
|
||||
const j = await r.json();
|
||||
document.getElementById('fetch-out').textContent =
|
||||
'OK status=' + r.status + '\n' + JSON.stringify(j, null, 2);
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 2. XHR a sibling JSON file
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', 'assets/data.json', false);
|
||||
xhr.send(null);
|
||||
document.getElementById('xhr-out').textContent =
|
||||
'OK status=' + xhr.status + '\n' + xhr.responseText;
|
||||
} catch (e) {
|
||||
document.getElementById('xhr-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 3. fetch a file in a subdirectory
|
||||
try {
|
||||
const r = await fetch('subdir/sub.json');
|
||||
const t = await r.text();
|
||||
document.getElementById('fetch-sub-out').textContent =
|
||||
'OK status=' + r.status + '\n' + t;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-sub-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 4. fetch a missing file
|
||||
try {
|
||||
const r = await fetch('assets/does-not-exist.json');
|
||||
document.getElementById('fetch-missing-out').textContent =
|
||||
'status=' + r.status + ' ok=' + r.ok;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-missing-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 5. fetch with a query string (should be ignored for file://)
|
||||
try {
|
||||
const r = await fetch('assets/data.json?cache=bust');
|
||||
const t = await r.text();
|
||||
document.getElementById('fetch-query-out').textContent =
|
||||
'OK status=' + r.status + '\n' + t;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-query-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Encoded Filename</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Encoded-Filename Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This file's name on disk is <code>encoded name.html</code>
|
||||
(with a literal space). The link from the home page uses
|
||||
<code>encoded%20name.html</code>.</p>
|
||||
<p>If you can read this, the browser correctly decoded the
|
||||
percent-encoding and loaded the right file.</p>
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
</main>
|
||||
<script>document.getElementById('url').textContent = location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Form Target</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Form Target</h1>
|
||||
<nav><a href="index.html">Home</a> | <a href="form.html">Back to form</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>The form submitted to this page. On a local file:// URL, GET forms
|
||||
should append a query string to the URL; POST forms have no server
|
||||
to receive the body, so the body is effectively dropped.</p>
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
<p>Query string: <span id="qs"></span></p>
|
||||
</main>
|
||||
<script>
|
||||
var u = new URL(location.href);
|
||||
document.getElementById('url').textContent = location.href;
|
||||
document.getElementById('qs').textContent = u.search || '(none)';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Form</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Form Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>GET form (submits to form-target.html)</h2>
|
||||
<form action="form-target.html" method="get">
|
||||
<label>Name: <input name="name" value="Alice"></label>
|
||||
<label>Age: <input name="age" value="30"></label>
|
||||
<button type="submit">Submit GET</button>
|
||||
</form>
|
||||
|
||||
<h2>POST form (submits to form-target.html)</h2>
|
||||
<form action="form-target.html" method="post">
|
||||
<label>Comment: <input name="comment" value="hello"></label>
|
||||
<button type="submit">Submit POST</button>
|
||||
</form>
|
||||
|
||||
<h2>Form with enctype=multipart/form-data</h2>
|
||||
<form action="form-target.html" method="post" enctype="multipart/form-data">
|
||||
<label>File: <input type="file" name="upload"></label>
|
||||
<button type="submit">Submit multipart</button>
|
||||
</form>
|
||||
|
||||
<h2>Form targeting a new window</h2>
|
||||
<form action="form-target.html" method="get" target="_blank">
|
||||
<label>Q: <input name="q" value="search"></label>
|
||||
<button type="submit">Submit to new window</button>
|
||||
</form>
|
||||
|
||||
<h2>Form targeting an iframe</h2>
|
||||
<form action="form-target.html" method="get" target="form-frame">
|
||||
<label>R: <input name="r" value="x"></label>
|
||||
<button type="submit">Submit to iframe</button>
|
||||
</form>
|
||||
<iframe name="form-frame" width="100%" height="120"></iframe>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Gallery</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Gallery</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="gallery.html">Gallery</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Images</h2>
|
||||
<figure>
|
||||
<img src="assets/logo.svg" alt="logo" width="200">
|
||||
<figcaption>logo.svg (relative path)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="assets/pic.svg" alt="pic" width="200">
|
||||
<figcaption>pic.svg (relative path)</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Picture element with srcset</h2>
|
||||
<picture>
|
||||
<source srcset="assets/logo.svg" type="image/svg+xml">
|
||||
<img src="assets/pic.svg" alt="fallback">
|
||||
</picture>
|
||||
|
||||
<h2>Image with srcset sizes</h2>
|
||||
<img src="assets/pic.svg"
|
||||
srcset="assets/pic.svg 1x, assets/logo.svg 2x"
|
||||
alt="srcset test" width="200">
|
||||
|
||||
<h2>Lazy-loaded image</h2>
|
||||
<img src="assets/pic.svg" loading="lazy" alt="lazy" width="200">
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Hash</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Hash Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="section-1">Section 1</h2>
|
||||
<p>Jump to <a href="#section-2">section 2</a> or
|
||||
<a href="#section-3">section 3</a>.</p>
|
||||
|
||||
<h2 id="section-2">Section 2</h2>
|
||||
<p>You arrived at section 2. The URL fragment should be
|
||||
<code>#section-2</code>.</p>
|
||||
|
||||
<h2 id="section-3">Section 3</h2>
|
||||
<p>End of the page.</p>
|
||||
|
||||
<p>Current hash: <span id="hash-out">—</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function report() {
|
||||
document.getElementById('hash-out').textContent = location.hash || '(none)';
|
||||
}
|
||||
report();
|
||||
window.addEventListener('hashchange', report);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — History</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>History Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>history.pushState</h2>
|
||||
<button id="push-btn">pushState({n:1}, '', '?pushed=1')</button>
|
||||
<p>URL after push: <span id="push-url">—</span></p>
|
||||
|
||||
<h2>history.replaceState</h2>
|
||||
<button id="replace-btn">replaceState({n:2}, '', '?replaced=1')</button>
|
||||
<p>URL after replace: <span id="replace-url">—</span></p>
|
||||
|
||||
<h2>popstate event</h2>
|
||||
<button id="back-btn">history.back()</button>
|
||||
<p>popstate fired: <span id="popstate-out">never</span></p>
|
||||
<p>URL on popstate: <span id="popstate-url">—</span></p>
|
||||
|
||||
<h2>Hash change</h2>
|
||||
<a href="#frag-1">Go to #frag-1</a>
|
||||
<p>hashchange fired: <span id="hash-out">never</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('push-btn').onclick = function () {
|
||||
try {
|
||||
history.pushState({ n: 1 }, '', '?pushed=1');
|
||||
$('push-url').textContent = location.href;
|
||||
} catch (e) { $('push-url').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('replace-btn').onclick = function () {
|
||||
try {
|
||||
history.replaceState({ n: 2 }, '', '?replaced=1');
|
||||
$('replace-url').textContent = location.href;
|
||||
} catch (e) { $('replace-url').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function (e) {
|
||||
$('popstate-out').textContent = 'yes (state=' + JSON.stringify(e.state) + ')';
|
||||
$('popstate-url').textContent = location.href;
|
||||
});
|
||||
|
||||
$('back-btn').onclick = function () {
|
||||
history.back();
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', function () {
|
||||
$('hash-out').textContent = 'yes (' + location.hash + ')';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Iframe</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Iframe Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Sibling page in an iframe</h2>
|
||||
<iframe src="about.html" width="100%" height="200" id="f1"></iframe>
|
||||
<p>iframe status: <span id="f1-status">loading…</span></p>
|
||||
|
||||
<h2>Subdirectory page in an iframe</h2>
|
||||
<iframe src="subdir/page.html" width="100%" height="200" id="f2"></iframe>
|
||||
<p>iframe status: <span id="f2-status">loading…</span></p>
|
||||
|
||||
<h2>Cross-iframe script access (same-origin for file://?)</h2>
|
||||
<button id="read-btn">Read iframe document title</button>
|
||||
<p>Result: <span id="read-result">not attempted</span></p>
|
||||
|
||||
<h2>srcdoc iframe</h2>
|
||||
<iframe srcdoc="<h1>Hello from srcdoc</h1><p><a href='index.html'>home</a></p>"
|
||||
width="100%" height="120"></iframe>
|
||||
|
||||
<h2>data: URI iframe</h2>
|
||||
<iframe src="data:text/html,<h1>Hello from data URI</h1>"
|
||||
width="100%" height="120"></iframe>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var f1 = document.getElementById('f1');
|
||||
var f2 = document.getElementById('f2');
|
||||
f1.addEventListener('load', function () {
|
||||
document.getElementById('f1-status').textContent = 'loaded';
|
||||
});
|
||||
f1.addEventListener('error', function () {
|
||||
document.getElementById('f1-status').textContent = 'ERROR';
|
||||
});
|
||||
f2.addEventListener('load', function () {
|
||||
document.getElementById('f2-status').textContent = 'loaded';
|
||||
});
|
||||
f2.addEventListener('error', function () {
|
||||
document.getElementById('f2-status').textContent = 'ERROR';
|
||||
});
|
||||
|
||||
document.getElementById('read-btn').addEventListener('click', function () {
|
||||
try {
|
||||
var t = f1.contentDocument.title;
|
||||
document.getElementById('read-result').textContent =
|
||||
'OK title="' + t + '"';
|
||||
} catch (e) {
|
||||
document.getElementById('read-result').textContent =
|
||||
'ERROR: ' + e.message;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Local Site — Home</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
<link rel="icon" href="assets/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Local Site — Home</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>
|
||||
<a href="subdir/page.html">Subdir Page</a>
|
||||
<a href="subdir/deep/deeper/deepest.html">Deep Link</a>
|
||||
<a href="404.html">Missing Page</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p>This is a multipage website loaded directly from the local filesystem
|
||||
(no web server). It is designed to exercise edge cases for local-file
|
||||
browsing in sovereign_browser.</p>
|
||||
|
||||
<h2>Edge cases covered</h2>
|
||||
<ul>
|
||||
<li>Relative links between pages in the same directory.</li>
|
||||
<li>Links into subdirectories and back out with <code>../</code>.</li>
|
||||
<li>Deeply nested paths (3 levels deep).</li>
|
||||
<li>External stylesheets and images loaded via relative paths.</li>
|
||||
<li>An SVG favicon.</li>
|
||||
<li>A page that fetches a local JSON file via <code>fetch()</code>.</li>
|
||||
<li>A page that uses <code>XMLHttpRequest</code> for a local file.</li>
|
||||
<li>A page with an inline iframe loading another local page.</li>
|
||||
<li>A page with a form that POSTs to another local page.</li>
|
||||
<li>A page that uses <code>window.open()</code> to open another local page.</li>
|
||||
<li>A page with a hash fragment link and <code>history.pushState</code>.</li>
|
||||
<li>A page that reads a local file via an external script tag.</li>
|
||||
<li>A page with a video and audio source.</li>
|
||||
<li>A page that uses <code>localStorage</code> and <code>sessionStorage</code>.</li>
|
||||
<li>A page with a <code>data:</code> URI and a <code>blob:</code> URI.</li>
|
||||
<li>A link to a missing page (404 / load-failure handling).</li>
|
||||
<li>A link with a query string (<code>?foo=bar</code>) on a local file.</li>
|
||||
<li>A link with a URL-encoded space in the filename.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Quick links</h2>
|
||||
<ul>
|
||||
<li><a href="data.html">Data page (fetch + XHR + JSON)</a></li>
|
||||
<li><a href="media.html">Media page (video/audio/image)</a></li>
|
||||
<li><a href="iframe.html">Iframe page</a></li>
|
||||
<li><a href="form.html">Form page (GET + POST)</a></li>
|
||||
<li><a href="storage.html">Storage page (localStorage/sessionStorage)</a></li>
|
||||
<li><a href="popup.html">Popup page (window.open)</a></li>
|
||||
<li><a href="history.html">History page (pushState/replaceState)</a></li>
|
||||
<li><a href="encoded%20name.html">URL-encoded filename page</a></li>
|
||||
<li><a href="query.html?foo=bar&baz=qux">Query-string page</a></li>
|
||||
<li><a href="hash.html#section-2">Hash-fragment page</a></li>
|
||||
<li><a href="scripts/external.html">External script page</a></li>
|
||||
<li><a href="advanced.html">Advanced JS page (ES Modules, Workers, WASM, cross-origin fetch)</a></li>
|
||||
<li><a href="storage-test.html">Automated storage test (all types)</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Inline image test</h2>
|
||||
<p>An image loaded from a relative path:</p>
|
||||
<img src="assets/logo.svg" alt="logo" width="120">
|
||||
|
||||
<h2>Inline script test</h2>
|
||||
<p id="inline-script-out">Inline script did not run.</p>
|
||||
<script src="assets/home.js"></script>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p><small>Loaded from: <span id="location"></span></small></p>
|
||||
</footer>
|
||||
<script>document.getElementById('location').textContent = window.location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Media</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Media Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>SVG image</h2>
|
||||
<img src="assets/pic.svg" alt="svg" width="300">
|
||||
|
||||
<h2>Inline SVG</h2>
|
||||
<svg width="120" height="60" viewBox="0 0 120 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="120" height="60" fill="#36c"/>
|
||||
<text x="60" y="36" text-anchor="middle" fill="white" font-family="sans-serif">Inline SVG</text>
|
||||
</svg>
|
||||
|
||||
<h2>Video (local MP4 source)</h2>
|
||||
<video controls width="480" poster="assets/pic.svg" id="vid">
|
||||
<source src="assets/sample.mp4" type="video/mp4">
|
||||
Your browser does not support video.
|
||||
</video>
|
||||
<p>Video readyState: <span id="video-ready">0</span></p>
|
||||
<p>Video networkState: <span id="video-net">0</span></p>
|
||||
<p>Video status: <span id="video-status">not loaded</span></p>
|
||||
<p>Video duration: <span id="video-dur">—</span> seconds</p>
|
||||
<p>Video dimensions: <span id="video-dim">—</span></p>
|
||||
|
||||
<h2>Audio (local M4A source)</h2>
|
||||
<audio controls id="aud">
|
||||
<source src="assets/Neon%20Dream.m4a" type="audio/mp4">
|
||||
Your browser does not support audio.
|
||||
</audio>
|
||||
<p>Audio readyState: <span id="audio-ready">0</span></p>
|
||||
<p>Audio networkState: <span id="audio-net">0</span></p>
|
||||
<p>Audio status: <span id="audio-status">not loaded</span></p>
|
||||
<p>Audio duration: <span id="audio-dur">—</span> seconds</p>
|
||||
|
||||
<h2>Object & embed</h2>
|
||||
<object data="assets/pic.svg" type="image/svg+xml" width="200"></object>
|
||||
<embed src="assets/pic.svg" width="200" type="image/svg+xml">
|
||||
|
||||
<h2>Missing video (error event test)</h2>
|
||||
<video controls width="320" id="vid-missing">
|
||||
<source src="assets/does-not-exist.mp4" type="video/mp4">
|
||||
</video>
|
||||
<p>Missing video status: <span id="vid-missing-status">not loaded</span></p>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var v = document.getElementById('vid');
|
||||
var a = document.getElementById('aud');
|
||||
var vm = document.getElementById('vid-missing');
|
||||
|
||||
function updateVideo() {
|
||||
document.getElementById('video-ready').textContent = v.readyState;
|
||||
document.getElementById('video-net').textContent = v.networkState;
|
||||
if (v.readyState >= 1) {
|
||||
document.getElementById('video-dur').textContent = v.duration ? v.duration.toFixed(1) : '—';
|
||||
}
|
||||
if (v.videoWidth > 0) {
|
||||
document.getElementById('video-dim').textContent = v.videoWidth + 'x' + v.videoHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (v) {
|
||||
v.addEventListener('loadeddata', function () {
|
||||
document.getElementById('video-status').textContent = 'loaded';
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('loadedmetadata', function () {
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('canplay', function () {
|
||||
document.getElementById('video-status').textContent = 'can play';
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('error', function () {
|
||||
document.getElementById('video-status').textContent = 'ERROR';
|
||||
});
|
||||
v.addEventListener('stalled', function () {
|
||||
document.getElementById('video-status').textContent = 'stalled';
|
||||
});
|
||||
}
|
||||
|
||||
if (a) {
|
||||
a.addEventListener('loadeddata', function () {
|
||||
document.getElementById('audio-status').textContent = 'loaded';
|
||||
document.getElementById('audio-ready').textContent = a.readyState;
|
||||
document.getElementById('audio-net').textContent = a.networkState;
|
||||
});
|
||||
a.addEventListener('loadedmetadata', function () {
|
||||
document.getElementById('audio-ready').textContent = a.readyState;
|
||||
document.getElementById('audio-net').textContent = a.networkState;
|
||||
if (a.duration) {
|
||||
document.getElementById('audio-dur').textContent = a.duration.toFixed(1);
|
||||
}
|
||||
});
|
||||
a.addEventListener('canplay', function () {
|
||||
document.getElementById('audio-status').textContent = 'can play';
|
||||
});
|
||||
a.addEventListener('error', function () {
|
||||
document.getElementById('audio-status').textContent = 'ERROR';
|
||||
});
|
||||
}
|
||||
|
||||
if (vm) {
|
||||
vm.addEventListener('error', function () {
|
||||
document.getElementById('vid-missing-status').textContent = 'ERROR (error event fired)';
|
||||
});
|
||||
vm.addEventListener('loadstart', function () {
|
||||
document.getElementById('vid-missing-status').textContent = 'loadstart fired';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Popup</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Popup Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>window.open() a sibling page</h2>
|
||||
<button id="open-btn">Open about.html in new window</button>
|
||||
<p>Result: <span id="open-out">not attempted</span></p>
|
||||
|
||||
<h2>window.open() with features</h2>
|
||||
<button id="open-feat-btn">Open gallery.html (width=400,height=400)</button>
|
||||
<p>Result: <span id="open-feat-out">not attempted</span></p>
|
||||
|
||||
<h2>window.open() then access popup.document</h2>
|
||||
<button id="open-access-btn">Open + read title</button>
|
||||
<p>Result: <span id="open-access-out">not attempted</span></p>
|
||||
|
||||
<h2>target="_blank" link</h2>
|
||||
<a href="about.html" target="_blank">Open about.html via target=_blank</a>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('open-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('about.html');
|
||||
$('open-out').textContent = w ? 'opened (name=' + w.name + ')' : 'blocked/null';
|
||||
} catch (e) { $('open-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('open-feat-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('gallery.html', 'gal', 'width=400,height=400');
|
||||
$('open-feat-out').textContent = w ? 'opened' : 'blocked/null';
|
||||
} catch (e) { $('open-feat-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('open-access-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('about.html');
|
||||
if (!w) { $('open-access-out').textContent = 'blocked/null'; return; }
|
||||
w.addEventListener('load', function () {
|
||||
try {
|
||||
$('open-access-out').textContent = 'title="' + w.document.title + '"';
|
||||
} catch (e) {
|
||||
$('open-access-out').textContent = 'access ERROR: ' + e.message;
|
||||
}
|
||||
});
|
||||
} catch (e) { $('open-access-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Query</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Query-String Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page was loaded with a query string. On a file:// URL the query
|
||||
string is preserved in <code>location.search</code> but should not
|
||||
affect which file is loaded.</p>
|
||||
<p>Full URL: <span id="url"></span></p>
|
||||
<p>Search: <span id="search"></span></p>
|
||||
<p>Parsed params:</p>
|
||||
<pre id="params"></pre>
|
||||
</main>
|
||||
<script>
|
||||
var u = new URL(location.href);
|
||||
document.getElementById('url').textContent = location.href;
|
||||
document.getElementById('search').textContent = u.search || '(none)';
|
||||
var params = {};
|
||||
u.searchParams.forEach(function (v, k) { params[k] = v; });
|
||||
document.getElementById('params').textContent =
|
||||
JSON.stringify(params, null, 2);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — External Script</title>
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>External Script Page</h1>
|
||||
<nav><a href="../index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page loads a script from <code>../assets/external.js</code>
|
||||
via an external script tag.</p>
|
||||
<p id="ext-out">External script did not run.</p>
|
||||
</main>
|
||||
<script src="../assets/external.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Storage Test</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Storage Test Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Results</h2>
|
||||
<pre id="results">Running tests...</pre>
|
||||
</main>
|
||||
<script>
|
||||
(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('=== Storage Test Start ===');
|
||||
add('URL: ' + location.href);
|
||||
add('');
|
||||
|
||||
// 1. localStorage
|
||||
add('--- localStorage ---');
|
||||
try {
|
||||
localStorage.setItem('ls_test', 'ls_value');
|
||||
add('SET: ok');
|
||||
add('GET: ' + localStorage.getItem('ls_test'));
|
||||
add('LENGTH: ' + localStorage.length);
|
||||
localStorage.removeItem('ls_test');
|
||||
add('REMOVE: ok, length now ' + localStorage.length);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 2. sessionStorage
|
||||
add('--- sessionStorage ---');
|
||||
try {
|
||||
sessionStorage.setItem('ss_test', 'ss_value');
|
||||
add('SET: ok');
|
||||
add('GET: ' + sessionStorage.getItem('ss_test'));
|
||||
add('LENGTH: ' + sessionStorage.length);
|
||||
sessionStorage.removeItem('ss_test');
|
||||
add('REMOVE: ok, length now ' + sessionStorage.length);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 3. Cookies
|
||||
add('--- Cookies (document.cookie) ---');
|
||||
try {
|
||||
document.cookie = 'sb_test=cookie_value; path=/';
|
||||
add('SET: ok');
|
||||
var match = document.cookie.match(/sb_test=([^;]+)/);
|
||||
add('GET: ' + (match ? match[1] : 'NOT FOUND'));
|
||||
add('ALL: ' + document.cookie);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 4. IndexedDB
|
||||
add('--- IndexedDB ---');
|
||||
add('available: ' + ('indexedDB' in window));
|
||||
try {
|
||||
var req = indexedDB.open('sb_test_db', 1);
|
||||
req.onupgradeneeded = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) {
|
||||
db.createObjectStore('kv');
|
||||
add('UPGRADE: created object store "kv"');
|
||||
}
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
add('OPEN: ok (name=' + db.name + ', version=' + db.version + ')');
|
||||
add('STORES: ' + Array.from(db.objectStoreNames).join(', '));
|
||||
|
||||
var tx = db.transaction('kv', 'readwrite');
|
||||
tx.objectStore('kv').put('idb_test_value', 'idb_test_key');
|
||||
tx.oncomplete = function () {
|
||||
add('PUT: ok (key=idb_test_key, value=idb_test_value)');
|
||||
|
||||
var tx2 = db.transaction('kv', 'readonly');
|
||||
var r = tx2.objectStore('kv').get('idb_test_key');
|
||||
r.onsuccess = function () {
|
||||
add('GET: ' + r.result);
|
||||
|
||||
// count entries
|
||||
var tx3 = db.transaction('kv', 'readonly');
|
||||
var cr = tx3.objectStore('kv').count();
|
||||
cr.onsuccess = function () {
|
||||
add('COUNT: ' + cr.result);
|
||||
add('INDEXEDDB: ALL PASS');
|
||||
testCacheAPI();
|
||||
};
|
||||
cr.onerror = function () { add('COUNT: error'); testCacheAPI(); };
|
||||
};
|
||||
r.onerror = function () { add('GET: error'); testCacheAPI(); };
|
||||
};
|
||||
tx.onerror = function () { add('PUT: tx error'); testCacheAPI(); };
|
||||
};
|
||||
req.onerror = function (e) {
|
||||
add('OPEN: error - ' + e.target.error);
|
||||
testCacheAPI();
|
||||
};
|
||||
req.onblocked = function () {
|
||||
add('OPEN: blocked');
|
||||
testCacheAPI();
|
||||
};
|
||||
} catch (e) {
|
||||
add('INDEXEDDB EXCEPTION: ' + e.message);
|
||||
testCacheAPI();
|
||||
}
|
||||
|
||||
// 5. Cache API (CacheStorage)
|
||||
function testCacheAPI() {
|
||||
add('');
|
||||
add('--- Cache API (CacheStorage) ---');
|
||||
add('available: ' + ('caches' in window));
|
||||
if (!('caches' in window)) {
|
||||
add('CACHE: not available');
|
||||
finishAll();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
caches.open('sb_test_cache').then(function (cache) {
|
||||
add('OPEN: ok (cache name=sb_test_cache)');
|
||||
|
||||
var testUrl = 'local://test-entry';
|
||||
var resp = new Response('cached body text', {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
|
||||
cache.put(new Request(testUrl), resp).then(function () {
|
||||
add('PUT: ok (url=' + testUrl + ')');
|
||||
|
||||
cache.match(new Request(testUrl)).then(function (matched) {
|
||||
if (matched) {
|
||||
matched.text().then(function (body) {
|
||||
add('MATCH: ok, body="' + body + '"');
|
||||
|
||||
cache.keys().then(function (keys) {
|
||||
add('KEYS: ' + keys.length + ' entries');
|
||||
add('CACHE: ALL PASS');
|
||||
finishAll();
|
||||
}).catch(function (e) {
|
||||
add('KEYS: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('MATCH BODY: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
} else {
|
||||
add('MATCH: no match found');
|
||||
finishAll();
|
||||
}
|
||||
}).catch(function (e) {
|
||||
add('MATCH: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('PUT: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('OPEN: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
} catch (e) {
|
||||
add('CACHE EXCEPTION: ' + e.message);
|
||||
finishAll();
|
||||
}
|
||||
}
|
||||
|
||||
function finishAll() {
|
||||
add('');
|
||||
add('=== Storage Test Complete ===');
|
||||
flush();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Storage</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Storage Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>localStorage</h2>
|
||||
<button id="ls-set">Set ls.foo = "bar"</button>
|
||||
<button id="ls-get">Get ls.foo</button>
|
||||
<p>Result: <span id="ls-out">not attempted</span></p>
|
||||
|
||||
<h2>sessionStorage</h2>
|
||||
<button id="ss-set">Set ss.foo = "baz"</button>
|
||||
<button id="ss-get">Get ss.foo</button>
|
||||
<p>Result: <span id="ss-out">not attempted</span></p>
|
||||
|
||||
<h2>IndexedDB</h2>
|
||||
<button id="idb-set">Set idb key</button>
|
||||
<button id="idb-get">Get idb key</button>
|
||||
<p>Result: <span id="idb-out">not attempted</span></p>
|
||||
|
||||
<h2>Cookies (document.cookie)</h2>
|
||||
<button id="ck-set">Set cookie foo=bar</button>
|
||||
<button id="ck-get">Get cookie</button>
|
||||
<p>Result: <span id="ck-out">not attempted</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('ls-set').onclick = function () {
|
||||
try { localStorage.setItem('foo', 'bar'); $('ls-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ls-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ls-get').onclick = function () {
|
||||
try { $('ls-out').textContent = 'value=' + localStorage.getItem('foo'); }
|
||||
catch (e) { $('ls-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('ss-set').onclick = function () {
|
||||
try { sessionStorage.setItem('foo', 'baz'); $('ss-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ss-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ss-get').onclick = function () {
|
||||
try { $('ss-out').textContent = 'value=' + sessionStorage.getItem('foo'); }
|
||||
catch (e) { $('ss-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('idb-set').onclick = function () {
|
||||
try {
|
||||
var req = indexedDB.open('testdb', 1);
|
||||
req.onupgradeneeded = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) db.createObjectStore('kv');
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
var tx = db.transaction('kv', 'readwrite');
|
||||
tx.objectStore('kv').put('hello', 'greeting');
|
||||
tx.oncomplete = function () { $('idb-out').textContent = 'set OK'; };
|
||||
tx.onerror = function (ev) { $('idb-out').textContent = 'tx ERROR: ' + ev.target.error; };
|
||||
};
|
||||
req.onerror = function (ev) { $('idb-out').textContent = 'open ERROR: ' + ev.target.error; };
|
||||
} catch (e) { $('idb-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('idb-get').onclick = function () {
|
||||
try {
|
||||
var req = indexedDB.open('testdb', 1);
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) {
|
||||
$('idb-out').textContent = 'no store yet';
|
||||
return;
|
||||
}
|
||||
var tx = db.transaction('kv', 'readonly');
|
||||
var r = tx.objectStore('kv').get('greeting');
|
||||
r.onsuccess = function () { $('idb-out').textContent = 'value=' + r.result; };
|
||||
r.onerror = function (ev) { $('idb-out').textContent = 'get ERROR: ' + ev.target.error; };
|
||||
};
|
||||
req.onerror = function (ev) { $('idb-out').textContent = 'open ERROR: ' + ev.target.error; };
|
||||
} catch (e) { $('idb-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('ck-set').onclick = function () {
|
||||
try { document.cookie = 'foo=bar; path=/'; $('ck-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ck-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ck-get').onclick = function () {
|
||||
try { $('ck-out').textContent = 'cookies=' + (document.cookie || '(none)'); }
|
||||
catch (e) { $('ck-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Deepest</title>
|
||||
<link rel="stylesheet" href="../../../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Deepest Page</h1>
|
||||
<nav>
|
||||
<a href="../../../index.html">Home (../../../)</a>
|
||||
<a href="../../page.html">Subdir page (../../)</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page is 3 directories deep. All links and assets use
|
||||
<code>../../../</code> to climb back to the root.</p>
|
||||
<img src="../../../assets/pic.svg" alt="pic via ../../../" width="200">
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
</main>
|
||||
<script>document.getElementById('url').textContent = location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Subdir Page</title>
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Subdir Page</h1>
|
||||
<nav>
|
||||
<a href="../index.html">Home (../)</a>
|
||||
<a href="../about.html">About (../)</a>
|
||||
<a href="deep/deeper/deepest.html">Deepest (relative)</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page lives in <code>subdir/</code>. Stylesheet and links use
|
||||
<code>../</code> to reach the root.</p>
|
||||
|
||||
<h2>Image via parent-relative path</h2>
|
||||
<img src="../assets/pic.svg" alt="pic via ../" width="200">
|
||||
|
||||
<h2>fetch() a sibling JSON in this subdir</h2>
|
||||
<pre id="out">not started.</pre>
|
||||
</main>
|
||||
<script>
|
||||
fetch('sub.json')
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (t) { document.getElementById('out').textContent = t; })
|
||||
.catch(function (e) { document.getElementById('out').textContent = 'ERROR: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"source": "subdir/sub.json",
|
||||
"loaded": true,
|
||||
"note": "This JSON is fetched from a subdirectory."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Test JSON",
|
||||
"version": "1.0",
|
||||
"nested": {
|
||||
"array": [1, 2, 3],
|
||||
"boolean": true,
|
||||
"null": null
|
||||
},
|
||||
"items": [
|
||||
{"id": 1, "label": "alpha"},
|
||||
{"id": 2, "label": "beta"},
|
||||
{"id": 3, "label": "gamma"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "nostr_url.h"
|
||||
#include <glib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *exact_npub =
|
||||
"npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06";
|
||||
|
||||
static void test_exact_npub(void) {
|
||||
nostr_decoded_entity_t decoded;
|
||||
GError *error = NULL;
|
||||
g_assert_true(nostr_url_decode(exact_npub, &decoded, &error));
|
||||
g_assert_no_error(error);
|
||||
g_assert_cmpint(decoded.type, ==, NOSTR_ENTITY_NPUB);
|
||||
char *hex = nostr_hex_encode32(decoded.pubkey);
|
||||
g_assert_cmpstr(hex, ==,
|
||||
"1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139");
|
||||
g_free(hex);
|
||||
nostr_decoded_entity_clear(&decoded);
|
||||
}
|
||||
|
||||
static void test_checksum_rejected(void) {
|
||||
char *bad = g_strdup(exact_npub);
|
||||
bad[strlen(bad) - 1] = bad[strlen(bad) - 1] == 'q' ? 'p' : 'q';
|
||||
nostr_decoded_entity_t decoded;
|
||||
GError *error = NULL;
|
||||
g_assert_false(nostr_url_decode(bad, &decoded, &error));
|
||||
g_assert_nonnull(error);
|
||||
g_clear_error(&error);
|
||||
g_free(bad);
|
||||
}
|
||||
|
||||
static void test_nsec_rejected(void) {
|
||||
const char *nsec =
|
||||
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme";
|
||||
nostr_decoded_entity_t decoded;
|
||||
GError *error = NULL;
|
||||
g_assert_false(nostr_url_decode(nsec, &decoded, &error));
|
||||
g_assert_nonnull(error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
g_test_init(&argc, &argv, NULL);
|
||||
g_test_add_func("/nostr-url/exact-npub", test_exact_npub);
|
||||
g_test_add_func("/nostr-url/checksum", test_checksum_rejected);
|
||||
g_test_add_func("/nostr-url/nsec", test_nsec_rejected);
|
||||
return g_test_run();
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/* Shared sovereign:// page CSS (theme variables, base elements, buttons).
|
||||
* Mirrors sovereign_page_css() in src/nostr_bridge.c so the chat page
|
||||
* stays consistent with settings/profile/bookmarks pages. */
|
||||
:root {
|
||||
--font: monospace;
|
||||
--primary: #000000;
|
||||
--secondary: #ffffff;
|
||||
--accent: #ff0000;
|
||||
--muted: #dddddd;
|
||||
--border: var(--muted);
|
||||
--radius: 5px;
|
||||
--bg: var(--secondary);
|
||||
--fg: var(--primary);
|
||||
--img-gray: 100%;
|
||||
--img-gray-hover: 20%;
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
--primary: #ffffff;
|
||||
--secondary: #000000;
|
||||
--accent: #ff0000;
|
||||
--muted: #777777;
|
||||
--border: var(--muted);
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { transition: background-color 0.2s ease, color 0.2s ease; }
|
||||
body { color: var(--fg); background: var(--bg); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; transition: 0.2s; }
|
||||
a:hover { text-decoration: underline; }
|
||||
input, select, textarea {
|
||||
background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 6px 10px; font-family: var(--font); font-size: 13px;
|
||||
min-width: 200px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
}
|
||||
.btn, .save-btn {
|
||||
background: var(--bg); color: var(--primary);
|
||||
border: 1px solid var(--primary); border-radius: var(--radius);
|
||||
padding: 6px 16px; cursor: pointer; font-family: var(--font);
|
||||
font-size: 13px; font-weight: bold; margin-left: 8px;
|
||||
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn:hover, .save-btn:hover { border-color: var(--accent); }
|
||||
.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-del { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-del:hover { background: var(--accent); color: var(--secondary); }
|
||||
|
||||
/* ── Chat page layout (tabbed) ───────────────────────────────── */
|
||||
#chat-root {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100vh; padding: 8px;
|
||||
}
|
||||
/* Tab bar — horizontal row of tab buttons. */
|
||||
#tab-bar {
|
||||
display: flex; flex-direction: row; gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; margin-bottom: 8px;
|
||||
}
|
||||
.tab {
|
||||
background: transparent; color: var(--fg);
|
||||
border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 14px; cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; font-weight: bold;
|
||||
margin-left: 0; transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--accent); }
|
||||
.tab.active {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Tab content — only the active tab is visible. */
|
||||
.tab-content {
|
||||
display: none; flex: 1; min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tab-content.active { display: flex; }
|
||||
/* Toolbar inside a tab (refresh + action buttons). */
|
||||
.tab-toolbar {
|
||||
display: flex; flex-direction: row; gap: 6px;
|
||||
flex-shrink: 0; margin-bottom: 8px;
|
||||
}
|
||||
.tab-toolbar .btn { margin-left: 0; }
|
||||
#new-chat-btn { margin-left: 0; }
|
||||
#conv-list {
|
||||
flex: 1; overflow-y: auto; display: flex;
|
||||
flex-direction: column; gap: 4px;
|
||||
}
|
||||
/* Conversation list items — match ai.html conversation styling:
|
||||
* title + timestamp on the left, delete button appears on hover. */
|
||||
.conv-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 10px; border-radius: var(--radius); cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.conv-item:hover {
|
||||
background: rgba(128,128,128,0.08);
|
||||
}
|
||||
.conv-active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255,0,0,0.06);
|
||||
}
|
||||
.conv-item-content {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.conv-title {
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; font-size: 0.9em; color: var(--fg);
|
||||
}
|
||||
.conv-timestamp {
|
||||
font-size: 0.72em; color: var(--muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.conv-del {
|
||||
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
|
||||
opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.conv-item:hover .conv-del { opacity: 1; }
|
||||
.conv-empty { color: var(--muted); font-size: 0.85em; padding: 8px; }
|
||||
#create-skill-btn { margin-left: 0; }
|
||||
/* Rename button — same hover-reveal behavior as the delete button. */
|
||||
.conv-rename {
|
||||
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
|
||||
opacity: 0; transition: opacity 0.15s; margin-left: 0;
|
||||
border-color: var(--border); color: var(--fg);
|
||||
}
|
||||
.conv-rename:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.conv-item:hover .conv-rename { opacity: 1; }
|
||||
/* "No chat selected" placeholder shown in the chat pane when no
|
||||
* conversation is active. */
|
||||
.no-chat-placeholder {
|
||||
color: var(--muted); font-style: italic; font-size: 0.9em;
|
||||
padding: 16px; text-align: center;
|
||||
}
|
||||
#skill-list {
|
||||
flex: 1; overflow-y: auto; display: flex;
|
||||
flex-direction: column; gap: 4px;
|
||||
}
|
||||
/* Skills list items — match ai.html skills styling:
|
||||
* checkbox + skill name + tool badges, with delete on hover for
|
||||
* user-authored skills. */
|
||||
.skill-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 8px; border-radius: var(--radius); flex-wrap: wrap;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.skill-item:hover {
|
||||
background: rgba(128,128,128,0.06);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.skill-checkbox { flex-shrink: 0; margin: 0; min-width: auto; }
|
||||
.skill-name { font-size: 0.88em; flex-shrink: 0; color: var(--fg); }
|
||||
.skill-badge {
|
||||
font-size: 0.7em; padding: 1px 6px; border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-badge-tools {
|
||||
background: rgba(0,180,0,0.15); color: #2a2;
|
||||
border: 1px solid rgba(0,180,0,0.4);
|
||||
}
|
||||
html.dark .skill-badge-tools {
|
||||
background: rgba(0,180,0,0.2); color: #6c6;
|
||||
border-color: rgba(0,180,0,0.5);
|
||||
}
|
||||
.skill-tool-enabled { border-left: 3px solid rgba(0,180,0,0.5); }
|
||||
.skill-item .conv-del {
|
||||
opacity: 0; padding: 1px 6px; font-size: 0.75em;
|
||||
}
|
||||
.skill-item:hover .conv-del { opacity: 1; }
|
||||
|
||||
/* ── Skill editor (inline, matching ai.html) ──────────────────── */
|
||||
.skill-badge-unsaved {
|
||||
background: rgba(255,165,0,0.15); color: #a60;
|
||||
border: 1px solid rgba(255,165,0,0.4);
|
||||
}
|
||||
html.dark .skill-badge-unsaved {
|
||||
background: rgba(255,165,0,0.2); color: #fc6;
|
||||
border-color: rgba(255,165,0,0.5);
|
||||
}
|
||||
.skillStackedItem {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 6px 8px; margin-bottom: 4px;
|
||||
background: rgba(128,128,128,0.04);
|
||||
}
|
||||
.skillStackedItem > summary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
cursor: pointer; flex-wrap: wrap; list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.skillStackedItem > summary::-webkit-details-marker { display: none; }
|
||||
.skillStackedItem > summary .skill-checkbox {
|
||||
flex-shrink: 0; margin: 0; min-width: auto;
|
||||
}
|
||||
.skillStackedItem > summary .skill-name {
|
||||
font-size: 0.88em; flex-shrink: 0; color: var(--fg);
|
||||
}
|
||||
.aiSkillControlsRow {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
margin-top: 8px; padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.aiSkillInlineField {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.aiSkillInlineField label {
|
||||
font-size: 0.75em; color: var(--muted); font-weight: bold;
|
||||
}
|
||||
.aiInput {
|
||||
background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
|
||||
min-width: 0; width: 100%; box-sizing: border-box;
|
||||
}
|
||||
.aiInput:focus { border-color: var(--accent); outline: none; }
|
||||
.taSkillTemplateEditor {
|
||||
background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
|
||||
width: 100%; box-sizing: border-box; min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
.taSkillTemplateEditor:focus { border-color: var(--accent); outline: none; }
|
||||
.aiSkillActionsRow {
|
||||
display: flex; gap: 6px; margin-top: 6px;
|
||||
}
|
||||
.aiSkillActionsRow .btn { margin-left: 0; }
|
||||
.aiSkillActionsRow .btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
#create-skill-form {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
margin-top: 4px; padding: 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: rgba(128,128,128,0.05);
|
||||
}
|
||||
.skill-input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
font-family: var(--font); font-size: 0.85em; padding: 4px 6px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--fg);
|
||||
}
|
||||
#skill-content { resize: vertical; min-height: 60px; }
|
||||
/* The chat tab content fills the available height: messages scroll,
|
||||
* input pinned to the bottom. */
|
||||
#tab-chat { min-width: 0; }
|
||||
#messages {
|
||||
flex: 1; overflow-y: auto; border: 1px solid #444;
|
||||
padding: 8px; margin-bottom: 8px; background: rgba(0,0,0,0.05);
|
||||
}
|
||||
/* ── Message bubbles (ported from ai.html messaging-ui.css) ────── */
|
||||
.msg-bubble-row {
|
||||
display: flex; width: 100%;
|
||||
}
|
||||
.msg-bubble-row.outgoing { justify-content: flex-end; }
|
||||
.msg-bubble-row.incoming,
|
||||
.msg-bubble-row.system { justify-content: flex-start; }
|
||||
.msg-bubble {
|
||||
max-width: 75%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 90%;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.msg-bubble.outgoing { border-color: var(--primary); }
|
||||
.msg-bubble.incoming { border-color: var(--muted); }
|
||||
.msg-bubble.system {
|
||||
border-style: dashed;
|
||||
border-color: var(--muted);
|
||||
color: var(--muted);
|
||||
font-size: 82%;
|
||||
}
|
||||
.msg-bubble-header {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 8px; margin-bottom: 6px;
|
||||
}
|
||||
.msg-bubble-header-main {
|
||||
min-width: 0; flex: 1; display: flex;
|
||||
align-items: center; gap: 8px;
|
||||
}
|
||||
.msg-bubble-sender-meta {
|
||||
min-width: 0; display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.msg-bubble-sender-name {
|
||||
font-size: 78%; color: var(--fg); font-weight: bold;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.msg-bubble-menu-host { flex-shrink: 0; }
|
||||
.msg-bubble-content { line-height: 1.35; }
|
||||
.msg-bubble-content > *:first-child { margin-top: 0; }
|
||||
.msg-bubble-content > *:last-child { margin-bottom: 0; }
|
||||
.msg-bubble-content p { margin: 0 0 0.3em 0; }
|
||||
.msg-bubble-content h1, .msg-bubble-content h2, .msg-bubble-content h3,
|
||||
.msg-bubble-content h4, .msg-bubble-content h5, .msg-bubble-content h6 {
|
||||
margin: 0.4em 0 0.2em 0; font-size: 1em;
|
||||
}
|
||||
.msg-bubble-content code {
|
||||
background: rgba(128,128,128,0.18);
|
||||
border-radius: 4px; padding: 1px 4px; font-size: 85%;
|
||||
}
|
||||
.msg-bubble-content pre {
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 6px; padding: 6px 8px; margin: 4px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.msg-bubble-content pre code {
|
||||
background: transparent; padding: 0;
|
||||
border-radius: 0; font-size: 90%;
|
||||
}
|
||||
.msg-bubble-content blockquote {
|
||||
margin: 4px 0; padding: 2px 8px;
|
||||
border-left: 3px solid var(--muted);
|
||||
color: var(--muted);
|
||||
}
|
||||
.msg-bubble-content a {
|
||||
color: var(--accent); text-decoration: underline; word-break: break-all;
|
||||
}
|
||||
.msg-bubble-content img {
|
||||
display: block; max-width: 100%;
|
||||
border-radius: 6px; margin: 4px 0;
|
||||
}
|
||||
.msg-bubble-content ul, .msg-bubble-content ol {
|
||||
margin: 4px 0; padding-left: 20px;
|
||||
}
|
||||
.msg-bubble-content table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
margin: 6px 0; font-size: 90%;
|
||||
}
|
||||
.msg-bubble-content th, .msg-bubble-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 8px; text-align: left; vertical-align: top;
|
||||
}
|
||||
.msg-bubble-content thead th { background: rgba(128,128,128,0.12); }
|
||||
.msg-bubble-content tbody tr:nth-child(even) { background: rgba(128,128,128,0.06); }
|
||||
.msg-bubble-content del, .msg-bubble-content s { opacity: 0.75; }
|
||||
.msg-bubble-content strong { font-weight: bold; }
|
||||
.msg-tcid { font-size: 0.8em; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
/* ── Dot-menu ────────────────────────────────────────────────────
|
||||
* The dot-menu styles now come from the shared sovereign://css/dot-menu.css
|
||||
* (copied from the client). Only the host sizing is page-specific. */
|
||||
.msg-bubble-menu-host .dot-menu-wrap { flex-shrink: 0; }
|
||||
|
||||
/* ── Tool calls, status message, input area ───────────────────── */
|
||||
.tool-calls { margin-top: 4px; }
|
||||
.tool-calls pre {
|
||||
font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Status message — a temporary system bubble shown in the chat window
|
||||
* (replaces the old #status-bar). It is italic and muted to distinguish
|
||||
* it from real agent responses. */
|
||||
#status-msg {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#status-msg-bubble {
|
||||
font-style: italic; color: var(--muted);
|
||||
max-width: 90%; border-style: dashed;
|
||||
}
|
||||
|
||||
/* The input area is a flex row: the composer (textarea + send button)
|
||||
* on the left taking all available space, and the Stop button on the
|
||||
* right (only visible while the agent is running). */
|
||||
#input-area {
|
||||
display: flex; align-items: flex-end; gap: 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 8px; background: var(--bg);
|
||||
}
|
||||
#composer-host {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
/* The shared post-composer library wraps the host element: it inserts a
|
||||
* .post-composer-wrapper around #composer-host and appends the send
|
||||
* button as a SIBLING of the host (inside the wrapper). So the send
|
||||
* button is NOT a descendant of #composer-host — it lives in
|
||||
* #input-area > .post-composer-wrapper > [.post-composer-editor-shell >
|
||||
* #composer-host, .post-composer-send-btn]. Target it via #input-area.
|
||||
* Make the wrapper fill the row and ensure the send button has a
|
||||
* visible border matching the page .btn style (post-composer.css uses
|
||||
* --primary-color which is undefined here, so we override it). */
|
||||
#input-area .post-composer-wrapper {
|
||||
width: 100%; min-width: 0; max-width: none; margin-bottom: 0;
|
||||
border: none; padding: 0; background: transparent;
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
}
|
||||
#input-area .post-composer-send-btn {
|
||||
margin-left: 0; min-width: 80px;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
#input-area .post-composer-send-btn:hover,
|
||||
#input-area .post-composer-send-btn:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
#input-area .post-composer-send-btn:disabled {
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
/* Stop-mode: the send button transforms into a stop button while the
|
||||
* agent is running. Use a distinct (red-ish) style to signal that
|
||||
* clicking it will cancel the agent. */
|
||||
#input-area .post-composer-send-btn.is-stop-mode {
|
||||
border-color: var(--danger, #c33);
|
||||
color: var(--danger, #c33);
|
||||
background: transparent;
|
||||
}
|
||||
#input-area .post-composer-send-btn.is-stop-mode:hover,
|
||||
#input-area .post-composer-send-btn.is-stop-mode:focus {
|
||||
background: var(--danger, #c33);
|
||||
color: #fff;
|
||||
border-color: var(--danger, #c33);
|
||||
}
|
||||
|
||||
/* ── Responsive: narrow sidebar viewport (< 400px) ──────────────
|
||||
* When the chat page is loaded in the agent sidebar (~350px wide),
|
||||
* switch to a compact layout: smaller fonts, shorter tab labels,
|
||||
* full-width messages, tighter padding. */
|
||||
@media (max-width: 400px) {
|
||||
#chat-root { padding: 4px; }
|
||||
.tab {
|
||||
padding: 6px 8px; font-size: 12px;
|
||||
}
|
||||
/* Shorten tab labels to single characters in narrow view. */
|
||||
.tab[data-tab="chat"] { font-size: 0; }
|
||||
.tab[data-tab="chat"]::before { content: "💬"; font-size: 14px; }
|
||||
.tab[data-tab="conversations"] { font-size: 0; }
|
||||
.tab[data-tab="conversations"]::before { content: "☰"; font-size: 14px; }
|
||||
.tab[data-tab="skills"] { font-size: 0; }
|
||||
.tab[data-tab="skills"]::before { content: "⚙"; font-size: 14px; }
|
||||
#messages {
|
||||
padding: 4px; font-size: 12px;
|
||||
}
|
||||
.msg-bubble {
|
||||
max-width: 92%; font-size: 85%; padding: 6px 8px;
|
||||
}
|
||||
.msg-bubble.system { font-size: 78%; }
|
||||
#input-area { padding: 4px; gap: 4px; }
|
||||
.tab-toolbar { gap: 4px; }
|
||||
.tab-toolbar .btn { padding: 4px 10px; font-size: 12px; }
|
||||
.conv-item { padding: 6px 8px; }
|
||||
.conv-title { font-size: 0.85em; }
|
||||
.skill-name { font-size: 0.82em; }
|
||||
.skill-input { font-size: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agent Chat</title>
|
||||
<link rel="stylesheet" href="sovereign://css/dot-menu.css">
|
||||
<link rel="stylesheet" href="sovereign://css/post-composer.css">
|
||||
<link rel="stylesheet" href="sovereign://agents/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat-root">
|
||||
<div id="tab-bar">
|
||||
<button class="tab active" data-tab="chat" onclick="switchTab('chat')">Chat</button>
|
||||
<button class="tab" data-tab="conversations" onclick="switchTab('conversations')">Conversations</button>
|
||||
<button class="tab" data-tab="skills" onclick="switchTab('skills')">Skills</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-chat" class="tab-content active">
|
||||
<div id="messages"></div>
|
||||
<div id="no-chat-placeholder" class="no-chat-placeholder">
|
||||
No conversations yet. Click ‘+ New Chat’ to start.
|
||||
</div>
|
||||
<div id="status-msg" class="msg-bubble-row system" style="display:none">
|
||||
<div class="msg-bubble system" id="status-msg-bubble"></div>
|
||||
</div>
|
||||
<div id="input-area">
|
||||
<div id="composer-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-conversations" class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<button id="new-chat-btn" class="btn" onclick="newChat()">+ New Chat</button>
|
||||
<button class="btn" onclick="fetchConvList()" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div id="conv-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-skills" class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<button id="create-skill-btn" class="btn" onclick="showCreateSkillForm()">+ Create Skill</button>
|
||||
<button class="btn" onclick="fetchSkills()" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div id="skill-list"></div>
|
||||
<div id="create-skill-form" style="display:none">
|
||||
<input id="skill-name" placeholder="Skill name" class="skill-input">
|
||||
<input id="skill-desc" placeholder="Description (optional)" class="skill-input">
|
||||
<textarea id="skill-content" rows="4" placeholder="System prompt template..." class="skill-input"></textarea>
|
||||
<input id="skill-tools" placeholder="Tools (comma-separated, optional)" class="skill-input">
|
||||
<button class="btn" onclick="publishSkill()">Publish</button>
|
||||
<button class="btn btn-del" onclick="hideCreateSkillForm()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="sovereign://vendor/marked.min.js"></script>
|
||||
<script src="sovereign://vendor/purify.min.js"></script>
|
||||
<script src="sovereign://js/dot-menu.js"></script>
|
||||
<script src="sovereign://js/post-composer.js"></script>
|
||||
<script src="sovereign://agents/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
/* Agent config page — sovereign://agents
|
||||
* Imports the shared sovereign:// base theme. */
|
||||
@import url("sovereign://sovereign-base.css");
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agent</title>
|
||||
<link rel="stylesheet" href="sovereign://agents/config.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Agent</h1>
|
||||
<p class="note">
|
||||
Configure the LLM provider for the embedded agent. Providers are
|
||||
shared across all apps (sovereign_browser, client, didactyl) via the
|
||||
Nostr <code>d:user-settings</code> event. The model, system prompt,
|
||||
and max iterations are per-app. Type a semicolon then a message in
|
||||
the URL bar to talk to the agent.
|
||||
</p>
|
||||
|
||||
<h2>Providers (shared)</h2>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Active provider</div></div>
|
||||
<div><select id="agent.provider" onchange="onProviderChange()"></select></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Provider name</div></div>
|
||||
<div><input type="text" id="provider_name" size="30">
|
||||
<button class="save-btn" onclick="saveProviderName()">Rename</button></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Base URL</div></div>
|
||||
<div><input type="text" id="agent.llm_base_url" size="50">
|
||||
<button class="save-btn" onclick="saveAndRefresh('agent.llm_base_url')">Save</button></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">API Key</div></div>
|
||||
<div><input type="text" id="agent.llm_api_key" size="50">
|
||||
<label style="margin-left:4px;font-size:12px">
|
||||
<input type="checkbox" id="api-key-hide"
|
||||
onchange="toggleKeyHide()">Hide</label>
|
||||
<button class="save-btn" onclick="saveAndRefresh('agent.llm_api_key')">Save</button></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Manage providers</div></div>
|
||||
<div><input type="text" id="new_provider_name" size="20"
|
||||
placeholder="new provider name">
|
||||
<button class="save-btn" onclick="addProvider()">Add</button>
|
||||
<button class="btn btn-del" onclick="removeProvider()">Remove Active</button></div>
|
||||
</div>
|
||||
|
||||
<h2>Agent Config (per-app)</h2>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Model</div></div>
|
||||
<div><select id="agent.llm_model" onchange="onModelChange()"></select>
|
||||
<input type="text" id="agent.llm_model_text" size="30"
|
||||
style="display:none;margin-left:4px"
|
||||
oninput="onModelTextInput()">
|
||||
<button id="refresh-models-btn" class="save-btn"
|
||||
onclick="refreshModels()">Refresh Models</button></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Max iterations</div></div>
|
||||
<div><input type="number" id="agent.max_iterations" min="1" max="10000">
|
||||
<button class="save-btn" onclick="save('agent.max_iterations')">Save</button></div>
|
||||
</div>
|
||||
|
||||
<h2>Sovereign Browser Skill</h2>
|
||||
<p class="note">
|
||||
The system prompt is treated as the default skill for sovereign_browser.
|
||||
Edit it here, then click "Save as Skill" to publish it to Nostr as a
|
||||
kind 31123 event (shareable with client, didactyl, and other apps).
|
||||
</p>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Name</div></div>
|
||||
<div><input type="text" id="agent.skill_name" size="50">
|
||||
<button class="save-btn" onclick="save('agent.skill_name')">Save</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Description</div></div>
|
||||
<div><input type="text" id="agent.skill_description" size="70">
|
||||
<button class="save-btn" onclick="save('agent.skill_description')">Save</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Template (system prompt)</div></div>
|
||||
<div><textarea id="agent.skill_template" rows="8" cols="70"></textarea>
|
||||
<button class="save-btn" onclick="save('agent.skill_template')">Save</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">Requires tools (comma-separated)</div></div>
|
||||
<div><input type="text" id="agent.skill_requires_tools" size="50">
|
||||
<button class="save-btn" onclick="save('agent.skill_requires_tools')">Save</button>
|
||||
<button class="save-btn" onclick="saveAsSkill()">Save as Skill</button></div>
|
||||
</div>
|
||||
|
||||
<p><a href="sovereign://agents/chat">Open Agent Chat</a></p>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<script src="sovereign://agents/config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user