Compare commits

...

24 Commits

Author SHA1 Message Date
Laan Tungir
473e2e4a96 v0.0.56 - Auto-fallback MCP port on conflict + discovery files for multi-instance support 2026-07-21 15:40:00 -04:00
Laan Tungir
9a55fe15f7 v0.0.55 - Fix n_signer USB serial login: send kind-27235 caller auth envelope and detect /dev/ttyUSB* devices 2026-07-21 15:34:45 -04:00
Laan Tungir
5e26c502ee v0.0.54 - Update vendored nostr_core_lib to v0.6.9 (NIP-03 OpenTimestamps expansion, prefixed n_signer verbs) 2026-07-20 17:38:19 -04:00
Laan Tungir
45aa46f6c4 v0.0.53 - Fix: login dialog window title shows 'sovereign browser v0.0.53' (matching main window); remove redundant app-name header from dialog body 2026-07-20 07:29:42 -04:00
Laan Tungir
480d758241 Fix: exclude large test media files from release tarball (mp4/m4a/webm/ogg) to avoid nginx 413 on upload 2026-07-20 06:57:08 -04:00
Laan Tungir
eb093ee16a v0.0.52 - Release: login dialog shows app version in title; n_signer key-index label live-updates BIP-32 derivation path 2026-07-20 06:55:37 -04:00
Laan Tungir
618d52ff69 v0.0.51 - Reduce terminal noise: suppress processes polling logs, remove perf-probe and inject success prints 2026-07-19 15:44:00 -04:00
Laan Tungir
3d32c64b0a v0.0.50 - CPU values: black text, red only for >=90%; remove all other heat colors and shaded backgrounds 2026-07-19 14:55:53 -04:00
Laan Tungir
ca2907b357 v0.0.49 - Fix: WebKit caching sovereign:// responses — add cache-busting to processes polls; remove shaded backgrounds from CPU values; remove debug prints 2026-07-19 14:13:43 -04:00
Laan Tungir
0892a9c7e1 v0.0.48 - Fix: heat bar text color readable on all backgrounds (grey-on-grey fix) 2026-07-19 10:21:47 -04:00
Laan Tungir
b3955e4231 v0.0.47 - Release: processes diagnostics window with per-tab CPU probe 2026-07-19 10:06:01 -04:00
Laan Tungir
3bacac4a42 Fix: prevent set -e from aborting before Gitea release creation; add status logging 2026-07-18 20:35:17 -04:00
Laan Tungir
fe1112f480 v0.0.46 - Release: target=_blank links open in new tab instead of new window 2026-07-18 20:28:51 -04:00
Laan Tungir
fa5dd93c6d v0.0.45 - Release: nested bookmark tree view with HMAC d tags, bookmarks toolbar, tarball cleanup trap, install next-steps fix 2026-07-18 17:51:54 -04:00
Laan Tungir
7d91a186d4 v0.0.44 - Move tab to new window on 'Open in New Window' (close original tab); keep app alive when main window closes if aux windows have tabs (delete-event handler) 2026-07-17 19:14:35 -04:00
Laan Tungir
6a8e51c812 v0.0.43 - Add new-tab button and avatar to auxiliary windows: extract setup_notebook_action_widgets helper, wire new-tab button to its own notebook, sync avatar across all windows 2026-07-17 19:01:12 -04:00
Laan Tungir
12f8b45015 v0.0.42 - Fix segfault on onion/fips/nostr URL redirect in new tab: defer webkit_web_view_load_uri to idle callback to avoid re-entrant navigation inside decide-policy handler 2026-07-17 18:35:36 -04:00
Laan Tungir
b90ea0bb11 v0.0.41 - Fix install.sh: FIPS prebuilt binary deleted before copy (RETURN trap) — use stable mktemp file 2026-07-17 12:41:59 -04:00
Laan Tungir
5b949ec034 v0.0.40 - install.sh: download FIPS prebuilt tarball from GitHub releases by default (source build via --build-fips-from-source) 2026-07-17 12:39:22 -04:00
Laan Tungir
43ee97bc38 v0.0.39 - Fix install.sh: FIPS is a Rust project — use cargo build --release, install rustup if needed, add libclang-dev 2026-07-17 12:33:02 -04:00
Laan Tungir
3ac4d4f9dc v0.0.38 - Fix install.sh apt package names for Debian 13 (trixie t64 transition) 2026-07-17 12:11:36 -04:00
Laan Tungir
b111fffeaa v0.0.37 - Fix install.sh: read confirmation from /dev/tty, fix color rendering in summary 2026-07-17 11:55:42 -04:00
Laan Tungir
7ca05baff1 v0.0.36 - Add Install section to README 2026-07-17 11:32:29 -04:00
Laan Tungir
6c1d309aac v0.0.35 - Selective Tor onion routing (tor:// scheme) + install.sh 2026-07-17 11:30:28 -04:00
69 changed files with 10821 additions and 951 deletions

7
.gitignore vendored
View File

@@ -7,6 +7,13 @@ sovereign_browser
*.dll
build/
# release tarballs (created by increment_and_push.sh -r, cleaned up by trap)
*.tar.gz
# compiled test binaries
tests/test_bookmarks_tree
tests/test_nostr_url
# editor / OS
*.swp
.DS_Store

View File

@@ -6,12 +6,15 @@
```bash
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
./browser.sh stop # kill running instance
./browser.sh stop # kill ALL running instances
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
./browser.sh status # list all running instances + MCP status
./browser.sh log # tail the browser log
./browser.sh mcp-url # print MCP endpoint URL(s) for running instance(s)
```
**Multiple instances:** if the default MCP port (17777) is already in use by another instance, the browser automatically falls back to an OS-assigned port and records it in `/tmp/sovereign_browser_<pid>.port`. `browser.sh` reads these discovery files to find/stop/status every instance. Use `./browser.sh mcp-url` to discover the actual endpoint(s).
## Login
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
@@ -36,7 +39,7 @@ Alternatively, you can still log in at runtime via the MCP `login` tool with `{"
## MCP Server
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP) for the first instance; subsequent instances get an OS-assigned port — use `./browser.sh mcp-url` to discover the actual endpoint(s)
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
- See `.roo/agents.md` for the full tool list and workflow

View File

@@ -21,7 +21,7 @@ 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/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
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c
# 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)

View File

@@ -96,6 +96,58 @@ Working browser with a broad feature set:
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
friction report from the POC phase.
## Install
One-command install on Debian 13 (trixie) or similar (x86_64, WebKitGTK 4.1):
```bash
curl -fsSL https://git.laantungir.net/laantungir/sovereign_browser/raw/branch/master/install.sh | bash
```
This downloads and runs [`install.sh`](install.sh), which:
1. Installs runtime dependencies via `apt` (WebKitGTK 4.1, libsoup, curl, etc.)
2. Installs **Tor** via `apt` (used for `.onion` routing)
3. Builds and installs **FIPS** from source with `CAP_NET_ADMIN` (used for `.fips` mesh routing)
4. Downloads the latest prebuilt `sovereign_browser` binary from the [Gitea releases](https://git.laantungir.net/laantungir/sovereign_browser/releases) (falls back to a source build if no binary is available)
5. Installs a `sovereign-browser` wrapper command to `/usr/local/bin`
After install, start the browser with:
```bash
sovereign-browser start --login-method generate
```
### Install options
```bash
# Non-root install to a user-writable prefix
curl -fsSL <url> | INSTALL_PREFIX=$HOME/.local bash -s -- --yes
# Force a source build instead of downloading the prebuilt binary
curl -fsSL <url> | bash -s -- --build-from-source
# Review the script before running it
curl -fsSL <url> -o install.sh
less install.sh
bash install.sh
```
Run `bash install.sh --help` for the full option list.
### What the install requires
| Requirement | Why |
|---|---|
| Debian 13+ / Ubuntu 24.04+ (WebKitGTK 4.1) | The browser is dynamically linked against `libwebkit2gtk-4.1` |
| x86_64 | Prebuilt binary is x86-64; arm64 needs `--build-from-source` |
| `sudo` / root | For `apt install`, `setcap` on the FIPS binary, and writing to `/usr/local` |
| Internet access | To download the binary, apt packages, and the FIPS source |
Tor and FIPS are **installed by default** (not optional). If either is absent at runtime the browser degrades gracefully — clearnet still works, `.onion`/`.fips` show an error page — but the installer sets them up so everything works out of the box.
---
## Build
Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:

View File

@@ -1 +1 @@
0.0.34
0.0.56

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -8,10 +8,16 @@
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill any running instance
# ./browser.sh stop — kill ALL running instances
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./browser.sh status — check if running + MCP responsive
# ./browser.sh status — list all running instances + MCP status
# ./browser.sh log — tail the browser log
# ./browser.sh mcp-url — print MCP endpoint URL(s) for running instance(s)
#
# Multiple instances: if the default MCP port (17777) is already in use by
# another instance, the browser automatically falls back to an OS-assigned
# port and records it in /tmp/sovereign_browser_<pid>.port. browser.sh
# reads these discovery files to find/stop/status every instance.
#
# Extra args after start/restart are forwarded to the binary, e.g.:
# ./browser.sh start --login-method generate --url https://example.com
@@ -25,7 +31,37 @@ set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
PORT=17777
DEFAULT_PORT=17777
DISCOVERY_GLOB="/tmp/sovereign_browser_*.port"
# Read the port for a given PID from its discovery file. Falls back to
# DEFAULT_PORT (17777) if no discovery file exists (e.g. older binary, or
# the first instance that successfully bound the default port and wrote a
# discovery file with 17777). Echoes the port to stdout.
port_for_pid() {
local pid="${1:-}"
local file="/tmp/sovereign_browser_${pid}.port"
if [[ -f "$file" ]]; then
local p
p=$(awk '{print $2}' "$file" 2>/dev/null | head -n1)
if [[ -n "$p" ]]; then
echo "$p"
return 0
fi
fi
echo "$DEFAULT_PORT"
}
# Echo the port for the currently-running instance (per get_pid), or
# DEFAULT_PORT if not running.
current_port() {
local pid
if pid=$(get_pid 2>/dev/null); then
port_for_pid "$pid"
else
echo "$DEFAULT_PORT"
fi
}
# Check if a PID is actually the sovereign_browser process.
# This safety guard prevents us from accidentally killing unrelated
@@ -53,13 +89,24 @@ get_pid() {
return 0
fi
fi
# Fallback: find the process LISTENing on the port.
# Fallback 1: scan discovery files for a live sovereign_browser PID.
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
echo "$dpid"
return 0
fi
done
# Fallback 2: find the process LISTENing on the default port.
# -sTCP:LISTEN matches only the listening socket, never client
# connections (e.g. Roo Code's MCP client). This is critical —
# without it, `lsof -ti :PORT` would also return Roo Code's PID
# and we'd kill the agent driving the browser.
local port_pid
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
port_pid=$(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
echo "$port_pid"
return 0
@@ -67,13 +114,36 @@ get_pid() {
return 1
}
# Echo all live sovereign_browser PIDs (one per line), discovered via the
# discovery files and the default-port listen socket. Used by cmd_stop to
# kill every running instance.
all_pids() {
local pids=()
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
pids+=("$dpid")
fi
done
local pp
for pp in $(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null || true); do
if is_browser_pid "$pp"; then
pids+=("$pp")
fi
done
# Deduplicate.
printf '%s\n' "${pids[@]}" 2>/dev/null | sort -u | grep -v '^$' || true
}
cmd_stop() {
local stopped_any=0
# Collect candidate PIDs from the PID file and from LISTENing sockets.
# We use -sTCP:LISTEN so we only match the server, never client
# connections (Roo Code / VS Code ext host connect to the MCP server
# and would be killed by a bare `lsof -ti :PORT`).
# Collect ALL running sovereign_browser PIDs from the PID file,
# discovery files, and the default-port listen socket. This kills
# every instance when multiple are running on different ports.
local candidates=()
if [[ -f "$PID_FILE" ]]; then
while IFS= read -r pid; do
@@ -82,7 +152,7 @@ cmd_stop() {
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
done < <(all_pids)
# Deduplicate and kill only verified sovereign_browser PIDs.
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
@@ -96,7 +166,7 @@ cmd_stop() {
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
for pid in $(all_pids); do
if is_browser_pid "$pid"; then
echo "[browser] Force killing PID $pid..."
kill -9 "$pid" 2>/dev/null || true
@@ -106,10 +176,21 @@ cmd_stop() {
fi
rm -f "$PID_FILE"
# Clean up any leftover discovery files (the browser removes its own on
# clean shutdown, but force-killed instances leave them behind).
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -z "$dpid" ]] || ! kill -0 "$dpid" 2>/dev/null; then
rm -f "$f"
fi
done
# Final check: is anything still LISTENing on the port?
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "[browser] Warning: process still listening on port $PORT."
# Final check: is anything still running?
if [[ -n "$(all_pids)" ]]; then
echo "[browser] Warning: some instances still running."
else
echo "[browser] Stopped."
fi
@@ -187,13 +268,18 @@ cmd_start() {
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# Wait for the MCP server to be ready (up to 10 seconds)
echo "[browser] Waiting for MCP server on port $PORT..."
# Wait for the MCP server to be ready (up to 10 seconds). The actual
# port may differ from DEFAULT_PORT if it was in use (the browser falls
# back to an OS-assigned port and writes it to a discovery file), so
# read the port from the discovery file each iteration.
echo "[browser] Waiting for MCP server (PID $pid)..."
local mcp_port=""
for i in $(seq 1 100); do
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
mcp_port=$(port_for_pid "$pid")
if curl -s -o /dev/null -X POST "http://localhost:$mcp_port/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server is ready (PID $pid)."
echo "[browser] MCP server is ready (PID $pid, port $mcp_port)."
return 0
fi
sleep 0.1
@@ -211,22 +297,51 @@ cmd_restart() {
}
cmd_status() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running."
return 0
fi
local count
count=$(echo "$pids" | wc -l)
local pid
if pid=$(get_pid 2>/dev/null); then
echo "[browser] Running (PID $pid)."
# Check MCP
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "[browser] Running (PID $pid, port $p)."
if curl -s -o /dev/null -X POST "http://localhost:$p/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server: responsive"
echo "[browser] MCP server: responsive"
else
echo "[browser] MCP server: NOT responding"
echo "[browser] MCP server: NOT responding"
fi
else
echo "[browser] Not running."
done
if [[ "$count" -gt 1 ]]; then
echo "[browser] $count instances running."
fi
}
# Print the MCP endpoint URL for the running instance (or all instances
# if multiple are running). Useful for external clients that need to
# discover the port programmatically:
# URL=$(./browser.sh mcp-url)
cmd_mcp_url() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running." >&2
return 1
fi
local pid
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "http://localhost:$p/mcp"
done
}
cmd_log() {
if [[ -f "$LOG" ]]; then
tail -50 "$LOG"
@@ -241,9 +356,11 @@ case "${1:-}" in
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
mcp-url) cmd_mcp_url ;;
*)
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
echo "Usage: $0 {start|stop|restart|status|log|mcp-url} [ARGS...]" >&2
echo " start/restart forward extra args to the binary." >&2
echo " mcp-url prints the MCP endpoint URL(s) for running instance(s)." >&2
exit 1
;;
esac

View File

@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# --- Cleanup trap -------------------------------------------------------
# Any temp files registered here are removed on exit, including when the
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
# leftover sovereign_browser-*.tar.gz files from accumulating in the
# project root when a release run is interrupted.
CLEANUP_FILES=()
cleanup_on_exit() {
for f in "${CLEANUP_FILES[@]}"; do
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
done
}
trap cleanup_on_exit EXIT INT TERM
register_cleanup() {
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
}
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
@@ -234,8 +250,8 @@ git_commit_and_push() {
fi
CURRENT_BRANCH=$(git branch --show-current)
git push origin "$CURRENT_BRANCH"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
}
# --- Release mode: build + Gitea release --------------------------------
@@ -270,6 +286,10 @@ create_source_tarball() {
--exclude='*.log' \
--exclude='*.tar.gz' \
--exclude='nostr_core_lib' \
--exclude='tests/local-site/assets/*.mp4' \
--exclude='tests/local-site/assets/*.m4a' \
--exclude='tests/local-site/assets/*.webm' \
--exclude='tests/local-site/assets/*.ogg' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
@@ -354,16 +374,24 @@ if [[ "$RELEASE_MODE" == true ]]; then
local_tarball=""
local_tarball=$(create_source_tarball || true)
# Register the tarball for cleanup so it's removed on exit even if a
# later step fails or the script is interrupted. The trap also catches
# the case where create_gitea_release aborts via `set -e`.
register_cleanup "$local_tarball"
print_status "Creating Gitea release for $NEW_VERSION..."
release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
print_status "Uploading release assets (binary + tarball)..."
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
else
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
fi
# Clean up the tarball (the binary is gitignored already).
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
# The tarball is removed by the cleanup_on_exit trap registered above,
# so no manual rm is needed here. The binary is gitignored already.
print_success "Release flow completed: $NEW_VERSION"
else

619
install.sh Executable file
View File

@@ -0,0 +1,619 @@
#!/bin/bash
set -euo pipefail
# install.sh — curl | bash installer for sovereign_browser
#
# Installs:
# * apt runtime deps (WebKitGTK 4.1, libsoup-3, tor, build toolchain)
# * FIPS prebuilt binary from GitHub releases (source-build fallback via
# --build-fips-from-source) with CAP_NET_ADMIN
# * sovereign_browser prebuilt binary from Gitea releases (source-build fallback)
# * a `sovereign-browser` wrapper that detaches the GUI
#
# Tor and FIPS are installed by default — they are NOT optional.
#
# Usage:
# curl -fsSL <url> | bash
# curl -fsSL <url> | bash -s -- --build-from-source
# ./install.sh [options]
# --- Config --------------------------------------------------------------
GITEA_API="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
GITEA_DOWNLOAD_BASE="https://git.laantungir.net/laantungir/sovereign_browser/releases/download"
SB_REPO="https://git.laantungir.net/laantungir/sovereign_browser.git"
FIPS_GITHUB_API="https://api.github.com/repos/jmcorgan/fips/releases/latest"
FIPS_REPO="https://github.com/jmcorgan/fips.git"
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}"
BIN_NAME="sovereign_browser"
# Runtime apt packages (non-dev). git/build-essential/pkg-config are included
# because they're needed for the FIPS source build and the source-build fallback.
APT_PACKAGES=(
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode4 libsqlite3-0
libsecp256k1-2 libssl3t64 libcurl4t64 zlib1g
tor git build-essential pkg-config
libcap2-bin
)
# libclang-dev is only needed when building FIPS from source (bindgen).
APT_PACKAGES_FIPS_SOURCE=(libclang-dev)
# --- Output helpers ------------------------------------------------------
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' NC=''
fi
print_info() { echo -e "${BLUE}[INFO]${NC} $*" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { print_error "$*"; exit 1; }
# --- Privilege helper ----------------------------------------------------
# Returns "sudo" if the target path is not user-writable, else "".
sudo_for() {
local target="$1"
if [[ -w "$target" ]]; then
echo ""
else
command -v sudo >/dev/null 2>&1 || die "sudo is required to write to $target but is not available."
echo "sudo"
fi
}
# --- Usage ---------------------------------------------------------------
show_usage() {
cat <<'EOF'
sovereign_browser installer
Usage: curl -fsSL <url> | bash
or: curl -fsSL <url> | bash -s -- --build-from-source
or: ./install.sh [options]
Options:
--build-from-source Build sovereign_browser from source instead of
downloading the prebuilt binary
--build-fips-from-source Build FIPS from source (cargo) instead of
downloading the prebuilt binary. Slower; needs
libclang-dev (auto-installed).
--prefix <dir> Install prefix (default: /usr/local)
--yes Skip confirmation prompts
-h, --help Show this help
EOF
}
# --- Args ----------------------------------------------------------------
BUILD_FROM_SOURCE=false
BUILD_FIPS_FROM_SOURCE=false
ASSUME_YES=false
while [[ $# -gt 0 ]]; do
case "$1" in
--build-from-source) BUILD_FROM_SOURCE=true; shift ;;
--build-fips-from-source) BUILD_FIPS_FROM_SOURCE=true; shift ;;
--prefix) INSTALL_PREFIX="${2:-}"; shift 2 ;;
--yes|-y) ASSUME_YES=true; shift ;;
-h|--help) show_usage; exit 0 ;;
*) die "Unknown option: $1 (try --help)" ;;
esac
done
# --- Platform detection --------------------------------------------------
detect_platform() {
print_info "Detecting platform..."
local os arch
os="$(uname -s)"
arch="$(uname -m)"
[[ "$os" == "Linux" ]] || die "This installer only supports Linux (got: $os)."
if [[ "$arch" != "x86_64" ]]; then
if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
die "arm64 is not supported for the prebuilt binary. Re-run with --build-from-source (arm64 source builds are untested)."
fi
die "Unsupported architecture: $arch (x86_64 only)."
fi
local distro_id="" version_id=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
distro_id="${ID:-}"
version_id="${VERSION_ID:-}"
fi
local distro_ok=false
if [[ "$distro_id" == "debian" ]]; then
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 13)}'; then
distro_ok=true
fi
elif [[ "$distro_id" == "ubuntu" ]]; then
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 24.04)}'; then
distro_ok=true
fi
fi
if $distro_ok; then
print_info "Distro: $distro_id $version_id (supported)."
else
print_warning "Distro '$distro_id $version_id' is not explicitly supported."
print_warning "WebKitGTK 4.1 runtime libs are required. If apt cannot find libwebkit2gtk-4.1-0, use Debian 13 (trixie)+ or Ubuntu 24.04+."
fi
}
# --- apt dependencies ----------------------------------------------------
install_deps() {
print_info "Installing apt dependencies (runtime libs, tor, build toolchain)..."
if ! command -v sudo >/dev/null 2>&1 && [[ $EUID -ne 0 ]]; then
die "sudo is required for apt-get but is not available (not running as root)."
fi
local SUDO=""
[[ $EUID -ne 0 ]] && SUDO="sudo"
$SUDO apt-get update
if ! $SUDO apt-get install -y "${APT_PACKAGES[@]}"; then
print_error "apt-get install failed. One or more packages could not be found."
print_error "This usually means your distro is too old and lacks WebKitGTK 4.1."
print_error "Use Debian 13 (trixie) or Ubuntu 24.04+, then re-run this installer."
exit 1
fi
print_success "Dependencies installed."
}
# --- FIPS ----------------------------------------------------------------
#
# FIPS is a Rust project (https://github.com/jmcorgan/fips). It publishes
# prebuilt release tarballs on GitHub Releases containing the `fips` binary
# plus tools (fipsctl, fipstop) and systemd units. We download the prebuilt
# x86_64 Linux tarball by default — much faster than a cargo build.
#
# With --build-fips-from-source, we instead clone the repo and run
# `cargo build --release` (requires Rust 1.94.1+ via rustup, and libclang-dev
# for the rustables/bindgen nftables bindings).
# Download the prebuilt FIPS tarball from GitHub Releases and extract the
# `fips` binary. Outputs a stable binary path on stdout (the file is copied
# to a caller-managed temp file so it survives after this function returns).
# Returns non-zero on failure so the caller can fall back to a source build.
download_fips_prebuilt() {
print_info "Querying GitHub for latest FIPS release..."
local api_json tag asset_url
if ! api_json="$(curl -fsSL "$FIPS_GITHUB_API" 2>/dev/null)"; then
print_warning "Could not reach FIPS GitHub releases API."
return 1
fi
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$tag" ]]; then
print_warning "Could not parse tag_name from FIPS GitHub release JSON."
return 1
fi
# Find the linux-x86_64 tarball asset URL.
asset_url="$(printf '%s' "$api_json" | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*linux-x86_64\.tar\.gz"' | head -1 | sed -E 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$asset_url" ]]; then
print_warning "No linux-x86_64 tarball found in FIPS release $tag."
return 1
fi
# Use a temp dir for download + extraction. We do NOT use a RETURN trap
# here because the caller needs to copy the binary out after this
# function returns — a RETURN trap would delete the file first. Instead
# we copy the binary to a stable temp file and clean up the extraction
# dir ourselves before returning.
local tmp fips_bin stable
tmp="$(mktemp -d -t fips_dl.XXXXXX)"
print_info "Downloading FIPS prebuilt binary: $asset_url"
if ! curl -fsSL -o "$tmp/fips.tar.gz" "$asset_url"; then
rm -rf "$tmp"
print_warning "FIPS tarball download failed."
return 1
fi
if ! tar -xzf "$tmp/fips.tar.gz" -C "$tmp" 2>/dev/null; then
rm -rf "$tmp"
print_warning "FIPS tarball extraction failed."
return 1
fi
# The tarball extracts to fips-<ver>-linux-x86_64/fips
fips_bin="$(find "$tmp" -type f -name fips 2>/dev/null | head -1 || true)"
if [[ -z "$fips_bin" ]] || ! file "$fips_bin" 2>/dev/null | grep -qi ELF; then
rm -rf "$tmp"
print_warning "FIPS tarball did not contain an ELF 'fips' binary."
return 1
fi
# Copy to a stable path so the caller can access it after we clean up
# the extraction dir. mktemp gives a file the caller can rm later.
stable="$(mktemp -t fips_binary.XXXXXX)"
cp -f "$fips_bin" "$stable"
chmod +x "$stable"
rm -rf "$tmp"
printf '%s' "$stable"
}
# --- Rust toolchain (only for --build-fips-from-source) ------------------
FIPS_MIN_RUST_MAJOR=1
FIPS_MIN_RUST_MINOR=94
rust_version_ok() {
command -v cargo >/dev/null 2>&1 || return 1
local ver
ver="$(cargo --version 2>/dev/null | grep -oE 'cargo [0-9]+\.[0-9]+\.' | head -1 | sed 's/cargo //; s/\.$//')" || return 1
[[ -z "$ver" ]] && return 1
local major minor
major="${ver%%.*}"
minor="${ver#*.}"
[[ "$major" -gt "$FIPS_MIN_RUST_MAJOR" ]] && return 0
[[ "$major" -eq "$FIPS_MIN_RUST_MAJOR" && "$minor" -ge "$FIPS_MIN_RUST_MINOR" ]] && return 0
return 1
}
ensure_rust() {
if rust_version_ok; then
print_info "Rust toolchain OK ($(cargo --version))."
return 0
fi
if command -v cargo >/dev/null 2>&1; then
print_warning "Installed cargo ($(cargo --version)) is older than FIPS requires (1.94.1+). Installing a newer toolchain via rustup."
else
print_info "No cargo found. Installing Rust toolchain via rustup."
fi
local rustup_init
rustup_init="$(mktemp -t rustup-init.XXXXXX)"
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$rustup_init" https://sh.rustup.rs; then
rm -f "$rustup_init"
die "Failed to download rustup installer."
fi
if ! sh "$rustup_init" -y --profile minimal >/dev/null 2>&1; then
rm -f "$rustup_init"
die "rustup installation failed."
fi
rm -f "$rustup_init"
# shellcheck disable=SC1091
. "$HOME/.cargo/env" 2>/dev/null || true
export PATH="$HOME/.cargo/bin:$PATH"
if ! rust_version_ok; then
die "Rust toolchain installed but cargo still reports an insufficient version. FIPS requires 1.94.1+."
fi
print_success "Rust toolchain installed: $(cargo --version)."
}
# Build FIPS from source via cargo. Outputs the binary path on stdout.
build_fips_from_source() {
print_info "Building FIPS from source ($FIPS_REPO)..."
ensure_rust
local tmp
tmp="$(mktemp -d)"
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
if ! git clone --depth 1 "$FIPS_REPO" "$tmp/fips"; then
die "Failed to clone FIPS repo: $FIPS_REPO"
fi
local fips_dir="$tmp/fips"
print_info "FIPS: running cargo build --release (this can take several minutes)..."
if ! ( cd "$fips_dir" && cargo build --release ); then
die "cargo build --release failed for FIPS. FIPS is required."
fi
local fips_bin=""
for cand in \
"$fips_dir/target/release/fips" \
"$fips_dir/target/x86_64-unknown-linux-gnu/release/fips"; do
if [[ -x "$cand" ]] && file "$cand" 2>/dev/null | grep -qi ELF; then
fips_bin="$cand"
break
fi
done
if [[ -z "$fips_bin" ]]; then
fips_bin="$(find "$fips_dir/target" -type f -name fips -executable 2>/dev/null | head -1 || true)"
fi
[[ -n "$fips_bin" ]] || die "FIPS build succeeded but no 'fips' binary was found."
printf '%s' "$fips_bin"
}
install_fips() {
print_info "Installing FIPS (required, not optional)..."
# If source build was requested, install the extra build-time apt deps.
if $BUILD_FIPS_FROM_SOURCE; then
print_info "Installing FIPS source-build deps (libclang-dev for bindgen)..."
local sudo_cmd=""
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
fi
local fips_bin=""
if $BUILD_FIPS_FROM_SOURCE; then
fips_bin="$(build_fips_from_source)"
else
if ! fips_bin="$(download_fips_prebuilt)"; then
print_warning "Prebuilt FIPS unavailable — falling back to source build."
# Install source-build deps on the fallback path too.
local sudo_cmd=""
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
fips_bin="$(build_fips_from_source)"
fi
fi
[[ -n "$fips_bin" && -x "$fips_bin" ]] || die "FIPS binary not available."
local prefix_bin="$INSTALL_PREFIX/bin"
local SUDO
SUDO="$(sudo_for "$INSTALL_PREFIX")"
$SUDO mkdir -p "$prefix_bin"
$SUDO cp -f "$fips_bin" "$prefix_bin/fips"
$SUDO chmod +x "$prefix_bin/fips"
print_success "FIPS binary installed to $prefix_bin/fips"
# Clean up the temp binary (download_fips_prebuilt copies to a stable
# mktemp file; build_fips_from_source uses a RETURN-trapped dir so its
# output is already gone, but rm -f is safe on either).
rm -f "$fips_bin" 2>/dev/null || true
# setcap for CAP_NET_ADMIN (needed to create a TUN interface in managed mode).
local setcap_cmd=""
if command -v setcap >/dev/null 2>&1; then
setcap_cmd="setcap"
elif command -v sudo >/dev/null 2>&1 && $SUDO command -v setcap >/dev/null 2>&1; then
setcap_cmd="$SUDO setcap"
fi
if [[ -n "$setcap_cmd" ]]; then
if $setcap_cmd cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
print_success "FIPS granted CAP_NET_ADMIN."
else
if $SUDO setcap cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
print_success "FIPS granted CAP_NET_ADMIN."
else
print_warning "setcap failed. FIPS managed mode (TUN) won't work without CAP_NET_ADMIN."
print_warning "Install 'libcap2-bin' and re-run, or run a system FIPS instance and set fips_mode=attach."
fi
fi
else
print_warning "'setcap' not found (libcap2-bin not installed)."
print_warning "FIPS managed mode won't work without CAP_NET_ADMIN."
print_warning "Install libcap2-bin (apt-get install -y libcap2-bin) and re-run, or run a system FIPS instance and set fips_mode=attach."
fi
}
# --- Prebuilt binary download -------------------------------------------
download_binary() {
# Outputs the downloaded binary path on stdout. Returns non-zero on failure
# so the caller can fall back to a source build.
print_info "Querying Gitea for latest release..."
local api_json tag url out
if ! api_json="$(curl -fsSL "$GITEA_API/releases/latest" 2>/dev/null)"; then
print_warning "Could not reach Gitea releases API (no release yet, or network issue)."
return 1
fi
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$tag" ]]; then
print_warning "Could not parse tag_name from Gitea release JSON."
return 1
fi
url="$GITEA_DOWNLOAD_BASE/$tag/$BIN_NAME"
out="$(mktemp -t sovereign_browser_download.XXXXXX)"
print_info "Downloading prebuilt binary: $url"
if ! curl -fsSL -o "$out" "$url"; then
rm -f "$out" 2>/dev/null || true
print_warning "Download failed for $url"
return 1
fi
if ! file "$out" 2>/dev/null | grep -qi ELF; then
rm -f "$out" 2>/dev/null || true
print_warning "Downloaded file is not an ELF binary."
return 1
fi
printf '%s\n' "$out"
print_success "Downloaded sovereign_browser $tag."
}
# --- Source build fallback -----------------------------------------------
build_from_source() {
print_info "Building sovereign_browser from source ($SB_REPO)..."
local tmp
tmp="$(mktemp -d)"
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
if ! git clone --depth 1 "$SB_REPO" "$tmp/sovereign_browser"; then
die "Failed to clone sovereign_browser repo: $SB_REPO"
fi
if ! ( cd "$tmp/sovereign_browser" && make ); then
die "Source build failed (make). Ensure build deps are installed."
fi
[[ -x "$tmp/sovereign_browser/$BIN_NAME" ]] || die "Build finished but ./$BIN_NAME was not produced."
local out
out="$(mktemp -t sovereign_browser_built.XXXXXX)"
cp -f "$tmp/sovereign_browser/$BIN_NAME" "$out"
chmod +x "$out"
printf '%s\n' "$out"
print_success "Built sovereign_browser from source."
}
# --- Install binary + wrapper -------------------------------------------
install_binary() {
local src="$1"
local prefix_bin="$INSTALL_PREFIX/bin"
local SUDO
SUDO="$(sudo_for "$INSTALL_PREFIX")"
print_info "Installing binary to $prefix_bin/$BIN_NAME"
$SUDO mkdir -p "$prefix_bin"
$SUDO cp -f "$src" "$prefix_bin/$BIN_NAME"
$SUDO chmod +x "$prefix_bin/$BIN_NAME"
# Write the kebab-case wrapper that detaches the GUI.
local wrapper="$prefix_bin/sovereign-browser"
local real_bin="$prefix_bin/$BIN_NAME"
print_info "Installing wrapper to $wrapper"
# Write to a temp file then move with sudo if needed.
local wtmp
wtmp="$(mktemp -t sovereign-browser-wrapper.XXXXXX)"
cat > "$wtmp" <<WRAPPER_EOF
#!/bin/bash
# sovereign-browser — user-facing wrapper that detaches the GUI.
# Mirrors a subset of browser.sh: start | stop | restart | status | log
set -euo pipefail
SB_BIN="$real_bin"
SB_DATA_DIR="\${HOME}/.sovereign_browser"
SB_PID_FILE="\${SB_DATA_DIR}/browser.pid"
SB_LOG_FILE="\${SB_DATA_DIR}/browser.log"
sb_start() {
mkdir -p "\$SB_DATA_DIR"
# If already running, just print status.
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
echo "sovereign_browser already running (PID \$(cat "\$SB_PID_FILE"))" >&2
return 0
fi
nohup "\$SB_BIN" "\$@" > "\$SB_LOG_FILE" 2>&1 &
local pid=\$!
echo "\$pid" > "\$SB_PID_FILE"
disown "\$pid" 2>/dev/null || true
echo "sovereign_browser started (PID \$pid)" >&2
}
sb_stop() {
if [[ ! -f "\$SB_PID_FILE" ]]; then
echo "sovereign_browser is not running (no PID file)" >&2
return 0
fi
local pid
pid="\$(cat "\$SB_PID_FILE" 2>/dev/null || true)"
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
kill "\$pid" 2>/dev/null || true
echo "sovereign_browser stopped (PID \$pid)" >&2
else
echo "sovereign_browser was not running (stale PID file)" >&2
fi
rm -f "\$SB_PID_FILE" 2>/dev/null || true
}
sb_status() {
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
echo "sovereign_browser is running (PID \$(cat "\$SB_PID_FILE"))" >&2
return 0
else
echo "sovereign_browser is not running" >&2
return 1
fi
}
sb_log() {
tail -n 50 "\$SB_LOG_FILE" 2>/dev/null || echo "(no log file yet)" >&2
}
case "\${1:-start}" in
start) shift 2>/dev/null || true; sb_start "\$@" ;;
stop) sb_stop ;;
restart) sb_stop; shift 2>/dev/null || true; sb_start "\$@" ;;
status) sb_status ;;
log) sb_log ;;
*) sb_start "\$@" ;; # default: treat all args as browser args
esac
WRAPPER_EOF
$SUDO mv -f "$wtmp" "$wrapper"
$SUDO chmod +x "$wrapper"
print_success "Wrapper installed: $wrapper"
if [[ ":${PATH}:" != *":$prefix_bin:"* ]]; then
print_warning "$prefix_bin is not in your PATH. Add it: export PATH=\"$prefix_bin:\$PATH\""
fi
}
# --- Main ----------------------------------------------------------------
main() {
detect_platform
# Summary + confirmation
local method="prebuilt binary from Gitea"
$BUILD_FROM_SOURCE && method="source build from $SB_REPO"
local fips_method="prebuilt binary from GitHub releases"
$BUILD_FIPS_FROM_SOURCE && fips_method="source build ($FIPS_REPO, cargo)"
# Use print_info so colors render via `echo -e` (a plain `cat` heredoc
# would print the literal \033 escape sequences).
print_info "sovereign_browser will be installed:"
print_info " Install prefix : $INSTALL_PREFIX"
print_info " Binary method : $method"
print_info " FIPS : $fips_method, CAP_NET_ADMIN set"
print_info " Tor : apt package 'tor'"
print_info " apt deps : ${APT_PACKAGES[*]}"
if ! $ASSUME_YES; then
print_warning "This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N]"
# Read from /dev/tty, not stdin: in `curl | bash` mode stdin is the
# curl pipe (the script text), so a plain `read` would hit EOF and
# abort immediately. /dev/tty is the user's terminal.
if [[ -r /dev/tty ]]; then
read -r reply < /dev/tty || reply=""
else
# No tty available (e.g. non-interactive CI) — require --yes.
die "No tty available for confirmation. Re-run with --yes to skip prompts."
fi
reply="${reply:-n}"
if [[ ! "${reply,,}" =~ ^y(es)?$ ]]; then
die "Aborted by user."
fi
fi
install_deps
install_fips
local bin_path=""
if $BUILD_FROM_SOURCE; then
bin_path="$(build_from_source)"
else
if ! bin_path="$(download_binary)"; then
print_warning "Prebuilt binary unavailable — falling back to source build."
bin_path="$(build_from_source)"
fi
fi
install_binary "$bin_path"
rm -f "$bin_path" 2>/dev/null || true
print_success "sovereign_browser installed."
cat >&2 <<EOF
${GREEN}[SUCCESS]${NC} Next steps:
Run: sovereign-browser
Or: sovereign-browser --login-method generate
Or: sovereign-browser --login-method generate --url https://example.com
Data dir: ~/.sovereign_browser/
Logs: sovereign-browser log
Stop: sovereign-browser stop
Status: sovereign-browser status
EOF
}
main "$@"

View File

@@ -0,0 +1,261 @@
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
## Goal
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
## NIP-51 Compliance + Privacy — How Nesting Works
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
```
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
```
### Event layout (one per folder)
```
{
"kind": 30003,
"pubkey": "<user pubkey hex>",
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
"content": "<NIP-44 encrypted JSON>",
...
}
```
The NIP-44 encrypted `content` plaintext is:
```json
{"path": "Work/Projects/Secret", "bookmarks": [
["bookmark", "https://example.com", "Example", 1690000000]
]}
```
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
### Tree reconstruction (client-side)
1. Fetch all `kind=30003` events for the user's pubkey.
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
4. Attach the decrypted `bookmarks` array to that leaf node.
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
### Why this is safe and nostr-ish
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
```mermaid
flowchart TD
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
B --> C["Read path from plaintext JSON"]
C --> D["Split path on / to get segments"]
D --> E["Build trie of folders via node_ensure_path"]
E --> F["Attach decrypted bookmarks to leaf node"]
F --> G["Render tree on sovereign://bookmarks page"]
G --> H["User adds/moves/renames/deletes"]
H --> I["Compute HMAC d tag for affected path(s)"]
I --> J["NIP-44 encrypt new content and publish kind 30003"]
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
```
---
## Implementation
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
#### New struct
```c
typedef struct bookmark_node {
char *name; /* last segment of path, e.g. "Secret" */
char *path; /* full path, e.g. "Work/Projects/Secret" */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
```
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
#### New / changed API
Keep the existing public functions working (they now operate on paths, not flat names):
- `bookmarks_add(const char *path, const char *url, const char *title)``path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
- `bookmarks_remove(const char *path, const char *url)`
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
#### Internal helpers
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
- `static void node_free(bookmark_node_t *node)` — recursive free.
- `static void compute_hmac_key(unsigned char out[32])``HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
- `static char *path_to_d_tag(const char *path)``HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
#### Loading
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
1. Read the `d` tag.
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
4. `node_ensure_path(root, path)`.
5. Load bookmarks into that leaf.
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
```json
{
"have_signer": true,
"tree": {
"name": "",
"path": "",
"bookmarks": [],
"children": [
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
]}
]
}
}
```
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
#### HTML
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
- Add a path breadcrumb / parent selector in the add form.
#### JS
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
- A bookmark list under the folder (collapsible).
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
#### CSS
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
- Caret uses a CSS triangle that rotates 90° when expanded.
- Indent `.tree-children` by `20px` per level.
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
#### Layout
```
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
[ ============ WebKitWebView ============ ]
```
#### Implementation
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
```c
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
```
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
#### Cross-tab refresh
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
### 6. History filter — [`src/history.c`](src/history.c:27)
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
### 7. Tests
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
- `node_ensure_path` creates intermediate nodes.
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
---
## Migration
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
## Files touched
| File | Change |
|------|--------|
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |

View File

@@ -0,0 +1,465 @@
# FIPS Management Page — `sovereign://fips`
## Goal
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
inside the browser. This is **not** part of the settings page — it's a
separate status + management page, like a network control panel.
The page lets the user:
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
tree state, ownership (managed vs attached), error messages.
2. **See connected peers** — list of peers with their npub, address,
transport, and connection state.
3. **Connect to a peer** — add a new peer by npub + address + transport.
4. **Disconnect a peer** — remove a peer connection.
5. **Restart the FIPS service** — stop + start the managed daemon, or
re-attach.
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
services, showing both on one "Network" page makes sense. Tor is
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
interactive management.
This makes `.fips` addresses actually usable: the user can add peers from
the browser UI instead of dropping to `fipsctl` on the command line.
---
## Current State
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
The browser already has a client:
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
connection to the socket.
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
`{"command":"show_status"}`, parses response into `fips_status_t`
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
tun_active).
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
`{"command":"show_peers"}`, returns the `data` field as a JSON string
(caller frees). **This is not currently called anywhere** — it exists
but is unused.
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
**Also unused.**
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
needs verification against the FIPS daemon's control protocol — see
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
`fips_control_disconnect_peer()` function.
### Net services layer (`src/net_services.c` / `src/net_services.h`)
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
`control_socket[512]`.
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
(managed) or 5s (attached) and updates the struct. It does **not** poll
`show_peers` — only the peer *count* is tracked.
- The service `state` field (`service_state_t`) tracks the lifecycle:
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
it disables then re-enables the service.
### sovereign:// page pattern (`src/nostr_bridge.c`)
The existing pages (settings, profile, bookmarks, agents) follow this
pattern:
1. **HTML page** served from an embedded `www/` file via
[`serve_embedded_file()`](src/nostr_bridge.c:132).
2. **CSS** served from `www/<name>.css`, linked via
`<link href="sovereign://fips.css">`.
3. **JS** served from `www/<name>.js`, loaded via
`<script src="sovereign://fips.js">`.
4. **JSON data endpoints**`sovereign://fips/status` returns JSON,
fetched by the JS on page load and on refresh.
5. **Action endpoints**`sovereign://fips/connect?npub=...&address=...&transport=...`
performs an action and returns JSON.
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
scheme handler doesn't support `fetch()` reliably for `sovereign://`
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
A `sovereignGet()` helper wraps XHR and returns a Promise.
Routing is in the main `bridge_handle_request()` function
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
---
## Design
### Page layout
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Status ──────────────────────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Node: npub1abc...xyz │ │
│ │ TUN: fips0 (active) │ │
│ │ Peers: 3 connected │ │
│ │ Tree: healthy │ │
│ │ Ownership: managed (spawned by browser) │ │
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
│ │ [Restart Service] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Peers ───────────────────────────────────────────────┐ │
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
│ │ npub: [npub1... ] │ │
│ │ address: [host:port ] │ │
│ │ transport: [udp ▼] │ │
│ │ [Connect] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Bootstrap: 100% │ │
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
│ │ Ownership: attached (system Tor) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Routing
| URI | Handler | Returns |
|-----|---------|---------|
| `sovereign://fips` | serve `www/fips.html` | HTML page |
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
| `sovereign://fips.js` | serve `www/fips.js` | JS |
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
### JSON endpoints
#### `sovereign://fips/status`
```json
{
"fips": {
"state": "ready",
"enabled": true,
"ownership": "managed",
"node_npub": "npub1abc...",
"peer_count": 3,
"tun_active": true,
"tun_name": "fips0",
"tree_state": "healthy",
"daemon_state": "running",
"control_socket": "~/.sovereign_browser/fips/control.sock",
"error": null
},
"tor": {
"state": "ready",
"enabled": true,
"ownership": "attached",
"bootstrap_progress": 100,
"socks_endpoint": "socks5://127.0.0.1:9050",
"circuit_info": "",
"error": null
}
}
```
When a service is `DISABLED` or `FAILED`, the fields are populated as
best as possible and `error` holds the failure message.
#### `sovereign://fips/peers`
Returns the raw `show_peers` data from the FIPS daemon (passed through
as-is, since the format is defined by FIPS):
```json
{
"peers": [
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
]
}
```
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
#### `sovereign://fips/connect`
Query params: `npub`, `address`, `transport` (optional, defaults to
`udp`).
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/disconnect`
Query params: `npub`.
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/restart`
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
`{"status": "ok"}` (the restart is async — the JS polls
`sovereign://fips/status` to see when it's ready again).
---
## Changes Required
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
```c
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size);
```
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
exact command name** against the FIPS daemon's control protocol — check
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
`disconnect_peer` or `remove_peer`, adjust accordingly.
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
New functions (following the existing handler pattern):
- `handle_fips_status_json(request)` — reads
`net_service_get_status(NET_SERVICE_FIPS)` and
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
the fields above, responds as JSON.
- `handle_fips_peers_json(request)` — opens a control connection to the
FIPS socket (from `status.fips.control_socket`), calls
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
responds as JSON. If FIPS is not ready, returns empty peers + error.
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
`transport` from query string, opens a control connection, calls
`fips_control_connect_peer()`, responds as JSON.
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
control connection, calls `fips_control_disconnect_peer()`, responds
as JSON.
- `handle_fips_restart(request)` — calls
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
**Control connection management:** Each action handler opens a fresh
control connection to the FIPS socket, sends the command, closes the
connection. This is simpler than reusing the `net_service_t`'s
`control_fd` (which is owned by the poll callback and may be in use).
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
line 3606), following the same pattern as the settings/bookmarks/agents
blocks:
```c
/* Route sovereign://fips — FIPS mesh management page. */
if (strcmp(uri, "sovereign://fips") == 0 ||
strncmp(uri, "sovereign://fips?", 18) == 0) {
serve_embedded_file(request, "fips.html");
return;
}
if (strcmp(uri, "sovereign://fips.css") == 0 ||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
serve_embedded_file(request, "fips.css");
return;
}
if (strcmp(uri, "sovereign://fips.js") == 0 ||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
serve_embedded_file(request, "fips.js");
return;
}
if (strcmp(uri, "sovereign://fips/status") == 0 ||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
handle_fips_status_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
handle_fips_peers_json(request);
return;
}
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
handle_fips_connect(request, uri + 24);
return;
}
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
handle_fips_disconnect(request, uri + 27);
return;
}
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
handle_fips_restart(request);
return;
}
```
### 4. Create `www/fips.html`
The HTML page. Links `fips.css` and `fips.js`. Structure:
- Status section (populated by JS from `sovereign://fips/status`)
- Peers table (populated by JS from `sovereign://fips/peers`)
- Add peer form (npub, address, transport dropdown, Connect button)
- Tor status section (read-only, from the same status JSON)
- Refresh button (re-fetches status + peers)
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
`sovereign-base.css` for theme variables, monospace font, dark theme.
### 5. Create `www/fips.css`
Page-specific styles. Import the base theme:
```css
@import "sovereign://sovereign-base.css";
```
Style the status cards, peers table, add-peer form. Match the existing
sovereign:// page aesthetic.
### 6. Create `www/fips.js`
The page logic:
```javascript
/* On load: fetch status + peers, populate DOM.
* Refresh button: re-fetch.
* Connect form: POST to sovereign://fips/connect, then refresh peers.
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
* Restart button: GET sovereign://fips/restart, then poll status.
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
*/
```
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
### 7. Add a link to the FIPS page from the settings page
In `www/settings.html` (or `www/settings.js`), add a link:
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
section. This makes the page discoverable.
### 8. No Makefile changes needed
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
sufficient — no Makefile edit required.
---
## Files
```
www/
├── fips.html # FIPS management page (new)
├── fips.css # Page styles (new)
├── fips.js # Page logic (new)
src/
├── nostr_bridge.c # Add FIPS route handlers + routing
├── fips_control.c # Add fips_control_disconnect_peer()
├── fips_control.h # Add disconnect_peer() declaration
```
---
## Implementation Phases
### Phase 1: Status page (read-only)
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
5. Show Tor status (read-only)
6. Auto-refresh every 5s
7. Add link from settings page
### Phase 2: Peer management (interactive)
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
5. Add peers table + add-peer form + disconnect buttons to the page
6. Test: connect to a known FIPS peer, verify it appears in the list,
verify `.fips` URLs to that peer resolve in the browser
### Phase 3: Service control
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
2. Add restart button to the page
3. Poll status during restart (state transitions: READY → STOPPING →
DISCOVERING → STARTING → READY)
4. Show error messages when state is FAILED
### Phase 4: Polish
1. Copy-to-clipboard for node npub
2. QR code for node npub (reuse `sovereign://qr?text=...`)
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
4. Transport auto-detect (try udp, then tcp)
5. Address book — save known peers in SQLite for re-connecting after
restart
---
## Testing
1. Build and start browser
2. Navigate to `sovereign://fips`
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
4. Verify Tor status shows correctly
5. Verify auto-refresh updates peer count if peers change
6. If FIPS is not running:
- Verify status shows "disabled" or "failed" with error message
- Verify peers list is empty with "FIPS not ready" message
7. Add a peer:
- Enter a known FIPS peer's npub + address + transport
- Click Connect
- Verify peer appears in the peers list
- Verify `http://<peer-npub>.fips/` loads in a tab
8. Disconnect a peer:
- Click the ✕ next to a peer
- Verify peer disappears from the list
9. Restart FIPS:
- Click Restart Service
- Verify state transitions through stopping → starting → ready
- Verify peers reconnect (if persistent) or need re-adding
---
## Open Questions
1. **FIPS disconnect command name** — is it `disconnect`,
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
control protocol docs or `fipsctl` source. The `connect` command is
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
assumed.
2. **Peer persistence** — when the browser restarts the managed FIPS
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
does the user need to re-add them every time? If non-persistent,
Phase 4's "address book" (saving peers in SQLite) becomes more
important.
3. **FIPS daemon control protocol docs** — the `show_peers` response
format is passed through as-is. We should verify the field names
(`npub`, `address`, `transport`, `state`) match what the JS expects.
If the format differs, the JS can adapt, but it's better to know
upfront.

255
plans/fips-network-view.md Normal file
View File

@@ -0,0 +1,255 @@
# FIPS Network View — `sovereign://fips/network`
## Goal
Extend the FIPS management page with a "Network" view that shows the
**entire mesh** as the local node knows it — not just direct peers, but
every node the daemon has discovered via bloom-filter propagation and
Nostr discovery. This answers "what can you see on FIPS?" and "can you
get a view of the entire network?"
## What the FIPS daemon exposes
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
these read-only commands relevant to a network view:
| Command | What it returns | Network-view use |
|---------|----------------|------------------|
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
### Key insight: "the entire network" = identity cache + tree
The FIPS daemon does **not** have a "ask peer X what peers it has"
command. The mesh is observed through two mechanisms:
1. **Bloom filters** — each node broadcasts a bloom filter of node
addresses it knows about. The local node unions these to estimate
total mesh size (`estimated_mesh_size`) and caches individual node
identities it has resolved (`show_identity_cache`).
2. **Spanning tree** — each node declares a parent; the tree
coordinates encode paths to the root. `show_tree` gives the local
node's tree position and its neighbors' positions.
So "the entire network" as seen from this node is:
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
last-seen). This is the node list for the network view.
- **`show_tree`** → the local tree topology (who is my parent, who are
my children, what is the root, what is the depth).
- **`show_status.estimated_mesh_size`** → a single estimated total.
We **cannot** build a full graph of who-connects-to-whom from the local
node's control socket alone — that would require every node to expose
its peer list, and FIPS doesn't do that (bloom filters propagate
*existence*, not *adjacency*). What we can show is:
- A **node list** (identity cache) — "these are all the nodes in the
mesh that this node knows about."
- A **tree view** — "this is my position in the spanning tree, my
parent, my children, the root."
- A **mesh summary** — "estimated N nodes total, tree depth D."
## Design
### Page layout
Add a "Network" tab/section to `sovereign://fips` (or a separate
`sovereign://fips/network` page linked from the main FIPS page).
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
│ [Status] [Peers] [Network] [Tree] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
│ │ Estimated mesh size: 47 nodes │ │
│ │ Known nodes (cache): 23 │ │
│ │ Tree depth: 4 │ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our position: depth 2, parent npub1def... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
│ │ ... │ │
│ │ (● = direct peer, ○ = known via discovery only) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our parent: npub1def... (fips.v0l.io) │ │
│ │ Our children: │ │
│ │ npub1jkl... depth 3 distance 1 │ │
│ │ npub1mno... depth 3 distance 1 │ │
│ │ Tree peers (1-hop): │ │
│ │ npub1abc... depth 0 root distance 2 │ │
│ │ npub1def... depth 1 parent distance 1 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### New JSON endpoints
| URI | FIPS command | Returns |
|-----|-------------|---------|
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
#### `sovereign://fips/network`
Calls three FIPS commands on a fresh control connection and combines
the results:
```json
{
"summary": {
"estimated_mesh_size": 47,
"known_nodes": 23,
"max_cache_entries": 256,
"tree_depth": 4,
"is_root": false,
"root_npub": "npub1abc...",
"root_display_name": "laantungir",
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
"our_parent": "npub1def...",
"our_parent_display_name": "fips.v0l.io"
},
"nodes": [
{
"node_addr": "e3da8293...",
"npub": "npub1abc...",
"display_name": "laantungir",
"ipv6_addr": "fde3:da82:...",
"last_seen_ms": 1784309108403,
"age_ms": 2000,
"is_direct_peer": true
}
],
"tree": {
"root": "1b4788b7...",
"root_npub": "npub1abc...",
"is_root": false,
"depth": 2,
"parent": "9d1192e8...",
"parent_display_name": "fips.v0l.io",
"peers": [
{
"node_addr": "1b4788b7...",
"display_name": "laantungir",
"depth": 0,
"distance_to_us": 2
}
]
}
}
```
The `is_direct_peer` flag on each node is computed by cross-referencing
the identity cache entries against the `show_peers` npub list (so the
UI can mark direct peers vs. discovery-only nodes).
### Implementation
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
These follow the existing `fips_control_show_peers()` pattern — send
the command, return the `data` field as a JSON string (caller frees):
```c
char *fips_control_show_tree(int fd, char *error, size_t error_size);
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
```
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
Opens a control connection, calls `show_status`, `show_peers`,
`show_identity_cache`, and `show_tree`, then combines them into the
JSON above. The `is_direct_peer` flag is computed by building a set of
direct-peer npubs from `show_peers` and checking each identity-cache
entry against it.
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
```c
if (strcmp(uri, "sovereign://fips/network") == 0 ||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
handle_fips_network_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
handle_fips_tree_json(request);
return;
}
```
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
Add tab navigation (Status | Peers | Network | Tree) to the page. The
Network tab fetches `sovereign://fips/network` and renders:
- Mesh summary card (estimated size, known nodes, depth, root, our
position).
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
- Spanning tree card (root, parent, children, 1-hop peers with
distances).
The Tree tab fetches `sovereign://fips/tree` and renders a more
detailed tree view (coords paths, declaration sequence).
#### 5. Auto-refresh
The Network tab polls `sovereign://fips/network` every 5s (same as the
status poll).
## Limitations (what we cannot show)
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
Bloom filters propagate node *existence*, not *edges*. To build a
full graph, every node would need to expose its peer list (a future
FIPS protocol extension), or the browser would need to query each
node's control socket individually (not practical across the mesh).
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
and `age_ms`, so we can show staleness, but there's no event stream
for join/leave (would need a FIPS control subscription protocol).
## Files
```
src/
├── fips_control.c # Add show_tree(), show_identity_cache()
├── fips_control.h # Add declarations
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
www/
├── fips.html # Add Network + Tree tabs
├── fips.css # Tab + network-table styles
├── fips.js # Network + tree rendering, tab switching
```
## Implementation phases
### Phase 1: Network summary + known nodes
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
2. Add `handle_fips_network_json()` combining status + peers + identity cache
3. Add `sovereign://fips/network` route
4. Add "Network" tab to the page with mesh summary + known-nodes table
5. Mark direct peers with a badge
### Phase 2: Spanning tree view
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
2. Add `sovereign://fips/tree` route
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
4. Optional: simple ASCII/indented tree rendering
### Phase 3: Polish
1. Sort known nodes by last-seen (most recent first)
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
3. Click a node to see its detail (ipv6, age, whether direct peer)
4. Copy-to-clipboard for npub / ipv6
5. Optional: bloom-filter visualization (advanced/debug tab)

295
plans/processes-window.md Normal file
View File

@@ -0,0 +1,295 @@
# Processes Window — Implementation Plan
## Goal
A `sovereign://processes` internal page that lets the user pinpoint which
open tab is consuming CPU and dig into *what that page is doing*. Primary
use case: diagnosing `~/lt/client`, which currently burns CPU across many
tabs.
## The core technical reality
The codebase deliberately **shares one WebProcess across tabs** — every
new tab/webview is created with `webkit_web_view_new_with_related_view()`
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
The plan therefore uses **two layers**:
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
the single-tab-per-process case and shows overall footprint.
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
is what actually pinpoints the offending tab *within* a shared
WebProcess and tells you what it is doing.
## Architecture
```mermaid
flowchart LR
subgraph BrowserProcess[sovereign_browser main PID]
ProcInfo[process_info.c reads /proc]
Bridge[nostr_bridge.c sovereign:// routes]
AgentServer[agent_server / MCP]
end
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
ProbeA[perf-probe.js in tab A]
ProbeB[perf-probe.js in tab B]
ProbeC[perf-probe.js in tab C]
end
ProbeA -->|sovereign://processes/probe-report POST| Bridge
ProbeB -->|sovereign://processes/probe-report POST| Bridge
ProbeC -->|sovereign://processes/probe-report POST| Bridge
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
Bridge -->|JSON| UI[www/processes.js renders tables]
Bridge -->|JSON| AgentServer
```
## Layer 1 — Process view (`src/process_info.{c,h}`)
### Process discovery
| Process | How found |
|---|---|
| Main (`sovereign_browser`) | `getpid()` |
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
### Per-process fields (all from `/proc`, no external deps)
| Field | Source |
|---|---|
| pid | — |
| name | `/proc/<pid>/comm` |
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
| ppid | `/proc/<pid>/stat` field 4 |
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
| rss_kb | `/proc/<pid>/status``VmRSS` |
| pss_kb | `/proc/<pid>/smaps_rollup``Pss` (fallback to RSS if unavailable) |
| uss_kb | `/proc/<pid>/smaps_rollup``Private_Clean` + `Private_Dirty` |
| vmpeak_kb | `/proc/<pid>/status``VmPeak` |
| vmswap_kb | `/proc/<pid>/status``VmSwap` |
| threads | count of entries in `/proc/<pid>/task/` |
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
| io_read_kb | `/proc/<pid>/io``read_bytes` / 1024 |
| io_write_kb | `/proc/<pid>/io``write_bytes` / 1024 |
| fd_count | count of entries in `/proc/<pid>/fd/` |
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
### CPU% computation
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
1. Read current `utime+stime` and `time(NULL)`.
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
3. Store current as prev.
Caller polls `sovereign://processes/list` every 1s; first call returns
0% (no baseline), second call onward is accurate. Same approach as
`top`/`htop`.
### API
```c
/* src/process_info.h */
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
```
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
Injected into every webview via `WebKitUserContentManager` (same pattern
as the existing `window.nostr` injection in
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
`WebKitUserScriptInjectAtDocumentStart`).
### Signals collected
| Signal | Source | What it tells you |
|---|---|---|
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
### Reporting
The probe batches a report every 1s and POSTs it to
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
route handler in `nostr_bridge.c` calls
`process_info_record_probe(N, body)`, which stores the latest probe per
tab index in a static array (sized to `tab_manager_count()`).
The probe must NOT run on `sovereign://` internal pages (settings, fips,
processes itself, agents) — skip injection when the URL scheme is
`sovereign://`. This avoids self-noise and recursion.
### Probe payload shape
```json
{
"tab_index": 3,
"cpu_busy_percent": 42.5,
"fps": 12,
"timer_count": 38,
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
"net_in_flight": 3,
"net_requests_last_sec": 8,
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
"heap_used_mb": 124.3,
"event_listener_count": 217,
"worker_count": 2,
"dom_node_count": 4821,
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
}
```
## Routes (added to `nostr_bridge.c`)
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
| Route | Handler | Returns |
|---|---|---|
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
## UI (`www/processes.{html,css,js}`)
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
### Tab 1 — Processes
Sortable table, default sort by `cpu_percent` desc:
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|---|---|---|---|---|---|---|---|---|
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
Click a WebKitWebProcess row → expands inline to list its hosted tabs
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
hot tab even within a shared process.
### Tab 2 — Tabs (the primary diagnostic view)
Sortable table, default sort by `cpu_busy_percent` desc:
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|---|---|---|---|---|---|---|---|---|---|
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
Click a tab row → **drill-down panel** opens below with:
- Long-task timeline (last 30s, sparkline)
- Top long-task sources (script URLs + total ms)
- Active timers list (code label, interval, count)
- Recent network requests (URL, type, count)
- Worker count, event listener count, heap trend
- Action buttons: **Reload tab**, **Suspend tab** (replace with
`about:blank` keeping URL), **Close tab**
### Styling
Reuse `sovereign-base.css` and the card/table styles from
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
(green→yellow→red by CPU%) for instant visual scan.
## Menu integration
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
(or opens a new tab if none active) — same pattern as
`open_fips_cb` at line 332.
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
`open_processes`.
## MCP tools (so an agent can self-diagnose)
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
[`agent_mcp.c`](../src/agent_mcp.c:1):
| Tool | Args | Returns |
|---|---|---|
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
and tell me what it's doing" — the agent calls `processes.tabs`, finds
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
drill-down, and reports the offending script/timer/endpoint.
## Build / packaging
- Add `src/process_info.c` to `Makefile` sources.
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
into the binary via `serve_embedded_file()`.
- Register the perf-probe user script in the shared
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
`main.c` near where `nostr_inject` is wired), with a URL filter that
excludes `sovereign://*`.
## Verification
1. `make`
2. `./browser.sh restart --login-method generate`
3. Open `sovereign://processes` — verify main + WebKit processes listed
with sane CPU%/RSS.
4. Open several tabs of a CPU-heavy page (e.g.
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
view ranks them by `cpu_busy_percent` and the drill-down shows
long-task sources.
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
identifiable and the drill-down shows which script/timer/endpoint is
responsible.
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
JSON shape matches the contract above.
## Out of scope (future work)
- Forcing process-per-tab via `WebKitWebsitePolicies` /
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
per-tab `/proc` attribution but breaks the existing
`related_view` sharing workaround. Tracked separately.
- Historical recording (save probe samples to SQLite for trend charts).
- Per-tab network throttling / CPU limiting (would need WebKit API
support that may not exist).
- Killing/suspending a tab's WebProcess specifically (currently only
per-tab reload/close, which is safe).

View File

@@ -0,0 +1,293 @@
# Selective Tor Routing — .onion Only
## Goal
sovereign_browser should make all address types "just work":
- `http://` / `https://` → direct (clearnet)
- `file://` → direct (local)
- `.onion` → through Tor SOCKS proxy
- `.fips` → through FIPS TUN interface
- `nostr://` → direct to relays (or through Tor if relay is `.onion`)
Tor and FIPS are **enabled by default**. Tor provides a SOCKS proxy that
is used **only** for `.onion` addresses. Regular internet traffic is
never routed through Tor.
This is NOT Tor Browser. It's a browser where `.onion` addresses just
work because Tor is running in the background.
---
## Current State
The current implementation (in `src/net_services.c`) sets WebKit's proxy
to route **all** HTTP/HTTPS through Tor when Tor is enabled (fail-closed
mode). This is the wrong default for sovereign_browser's vision — it
would break clearnet access and isn't what we want.
---
## Design
### Traffic Routing
| URL pattern | Route | How |
|-------------|-------|-----|
| `http://example.com` | Direct | WebKit default networking, no proxy |
| `https://example.com` | Direct | WebKit default networking, no proxy |
| `file:///path` | Direct | Local file access |
| `http://something.onion` | Tor SOCKS | Custom scheme handler |
| `https://something.onion` | Tor SOCKS | Custom scheme handler |
| `http://npub1abc.fips/` | FIPS TUN | DNS resolves to fd00::/8, TUN routes |
| `nostr://npub1...` | Direct to relays | nostr:// scheme handler |
| `sovereign://...` | Direct (internal) | sovereign:// scheme handler |
### WebKit Proxy Configuration
**Always `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.** WebKit never uses a
proxy for its default network stack. All proxying is done via custom
URI scheme handlers.
This is a change from the current code, which sets `CUSTOM` mode with
Tor's SOCKS endpoint. We remove that entirely.
### .onion Handling
#### Approach: Intercept + custom scheme
1. **`on_decide_policy` in `tab_manager.c`** intercepts navigation to
`.onion` URLs (both `http://*.onion` and `https://*.onion`).
2. **Rewrite to `tor://` scheme:** The intercepted URL is rewritten:
- `http://abc.onion/path?q=1``tor://abc.onion/path?q=1`
- `https://abc.onion/path``tor://abc.onion/path`
- The scheme handler knows to use Tor's SOCKS proxy.
3. **`tor://` URI scheme handler** (new `src/tor_scheme.c`):
- Registered via `webkit_web_context_register_uri_scheme(ctx, "tor", ...)`
- Parses the `.onion` hostname, port, path, query from the URI
- Fetches the content via Tor's SOCKS proxy using libcurl with
`CURLOPT_PROXY` set to Tor's SOCKS endpoint
- Returns the response (headers + body) to WebKit via
`webkit_uri_scheme_request_finish()`
- Supports both HTTP and HTTPS over .onion (Tor handles the TLS)
4. **URL bar:** When the user types `abc.onion` in the URL bar,
`normalize_url()` should recognize `.onion` and prepend `http://`
(or `https://`), then the `on_decide_policy` interception handles
the rest.
5. **Links on pages:** When a web page has `<a href="http://abc.onion">`,
clicking it triggers `on_decide_policy`, which intercepts and
rewrites to `tor://abc.onion`.
### Why a custom scheme handler instead of WebKit proxy?
WebKitGTK's proxy API (`WebKitNetworkProxySettings`) only supports
"proxy everything, ignore these hosts" — not "proxy only these hosts."
Setting a proxy for everything would route clearnet through Tor, which
we don't want. A custom scheme handler gives us precise control: only
`.onion` traffic goes through Tor.
The downside is that the custom scheme handler must handle HTTP
semantics (GET, POST, headers, cookies, redirects) itself. However,
libcurl handles all of this — we just pass the request to curl with
the SOCKS proxy configured and return the response.
### libcurl for .onion fetches
The `tor://` scheme handler uses libcurl (already a dependency — used
in `nostr_scheme.c` for NIP-11 fetches) with:
```c
curl_easy_setopt(curl, CURLOPT_URL, "http://abc.onion/path?q=1");
curl_easy_setopt(curl, CURLOPT_PROXY, "socks5h://127.0.0.1:9050");
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
```
`socks5h://` means curl sends the hostname to the SOCKS proxy (Tor
resolves it — no DNS leak). `CURLPROXY_SOCKS5_HOSTNAME` ensures the
.onion hostname is resolved by Tor, not locally.
For HTTPS over .onion, curl handles TLS normally — Tor just provides
the transport.
### Request handling
The scheme handler must handle:
1. **GET requests** — fetch URL via curl, return body + content type
2. **POST requests** — WebKit custom scheme handlers don't directly
expose POST bodies. This is a known WebKitGTK limitation. For Phase 1,
POST to .onion may not work (GET is sufficient for most .onion sites
and Nostr relay web interfaces). A future enhancement could use a
WebExtension for POST body access.
3. **Redirects** — curl follows redirects by default
(`CURLOPT_FOLLOWLOCATION`). The final URL is returned.
4. **Headers** — curl captures response headers. We pass through
Content-Type so WebKit renders correctly (HTML, JSON, images, etc.).
5. **Cookies** — .onion cookies are handled by curl's cookie engine
(separate from WebKit's cookie jar). For Phase 1, this is acceptable.
Future: share cookies between WebKit and the Tor handler.
6. **Timeouts** — 30 second timeout for .onion (Tor can be slow).
### Async handling
Like `nostr_scheme.c`, the `tor://` handler uses `GTask` to run the
curl fetch on a worker thread and finish the WebKit request on the main
context. This prevents blocking the UI.
### Tor SOCKS endpoint
The Tor SOCKS endpoint is determined by the existing Tor service
management in `net_services.c` / `tor_control.c`:
- **Attached Tor:** use the discovered SOCKS endpoint
(e.g., `socks5://127.0.0.1:9050`)
- **Managed Tor:** use the private Unix socket
(e.g., `socks5://unix:/home/user/.sovereign_browser/tor/socks.sock`)
The `tor://` scheme handler queries `net_service_get_status(NET_SERVICE_TOR)`
to get the current SOCKS endpoint. If Tor is not ready, it returns an
error page.
### FIPS .onion relays
If a Nostr relay URL is `wss://relay.onion`, the C-side relay fetch
code (`relay_fetch.c`) would need to connect through Tor's SOCKS proxy.
This is a future enhancement — the nostr_core_lib relay client would
need SOCKS support. For Phase 1, only WebKit .onion browsing is
supported.
---
## Changes Required
### 1. Change WebKit proxy to always NO_PROXY
In `src/net_services.c`, `net_services_refresh_proxy()`:
- **Remove** the current logic that sets `CUSTOM` proxy mode with Tor's
SOCKS endpoint.
- **Always** set `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.
- Tor proxying is now handled entirely by the `tor://` scheme handler,
not by WebKit's proxy settings.
- Keep the function (it's called from various places) but simplify it
to always set NO_PROXY. Or remove the calls and just set NO_PROXY
once at startup.
### 2. Create `src/tor_scheme.c` / `src/tor_scheme.h`
New URI scheme handler for `tor://`:
```c
void tor_scheme_register(WebKitWebContext *ctx);
```
- Registers the `tor` URI scheme
- On request: parse the .onion URL, get Tor SOCKS endpoint from
`net_service_get_status(NET_SERVICE_TOR)`, fetch via libcurl with
SOCKS proxy, return response to WebKit
- Async via `GTask` (same pattern as `nostr_scheme.c`)
- Returns error JSON if Tor is not ready
### 3. Intercept .onion in `on_decide_policy`
In `src/tab_manager.c`, `on_decide_policy()`:
```c
/* Check if the navigation target is a .onion URL */
if (uri && (g_str_has_suffix(host, ".onion"))) {
/* Rewrite to tor:// scheme */
char *tor_url = g_strdup_printf("tor://%s", uri + strlen("http://"));
/* or preserve https:// → tor:// */
webkit_web_view_load_uri(webview, tor_url);
g_free(tor_url);
webkit_policy_decision_ignore(decision);
return TRUE;
}
```
Also intercept in `normalize_url()` so the URL bar shows the original
`http://abc.onion` form (not `tor://abc.onion`).
### 4. Register tor:// scheme in `main.c`
```c
tor_scheme_register(web_ctx);
```
### 5. Enable Tor and FIPS by default
In `src/settings.c`, change defaults:
```c
s->tor_enabled = TRUE; /* was FALSE */
s->fips_enabled = TRUE; /* was FALSE */
```
### 6. Update Makefile
Add `src/tor_scheme.c` to `SRC`.
### 7. Update `net_services_refresh_proxy()`
Simplify to always set NO_PROXY. Remove the fail-closed logic (no
longer needed — clearnet always works, .onion goes through the scheme
handler).
---
## Files
```
src/
├── tor_scheme.c # tor:// URI scheme handler (new)
├── tor_scheme.h # Public API (new)
├── net_services.c # Simplified: always NO_PROXY
├── tab_manager.c # .onion interception in on_decide_policy + normalize_url
├── main.c # Register tor:// scheme
├── settings.c # Tor/FIPS enabled by default
└── Makefile # Add tor_scheme.c
```
---
## Implementation Phases
### Phase 1: .onion browsing via tor:// scheme
1. Create `tor_scheme.c` — curl-based .onion fetcher with SOCKS proxy
2. Register `tor://` scheme in `main.c`
3. Intercept `.onion` in `on_decide_policy` and `normalize_url`
4. Simplify `net_services_refresh_proxy()` to always NO_PROXY
5. Enable Tor and FIPS by default in settings
6. Test: `http://duckduckgogg42tjsool4r5mn3r2onion.onion` loads through Tor
### Phase 2: Polish
1. Handle .onion redirects (curl follows, but URL bar should show final)
2. Error pages for .onion when Tor is not ready
3. Cookie handling for .onion (curl cookie jar)
4. Timeout handling (30s for .onion)
5. Content-type passthrough (HTML, JSON, images, CSS, JS)
### Phase 3: .onion Nostr relays (future)
1. Add SOCKS proxy support to relay fetch code
2. Allow `wss://relay.onion` in relay configuration
3. Route .onion relay connections through Tor
---
## Testing
1. Build and start browser
2. Verify `http://example.com` loads directly (not through Tor)
3. Verify `file:///path` works
4. If Tor binary is available:
- Verify `http://something.onion` loads through Tor
- Verify Tor log shows the .onion connection
5. If Tor binary is not available:
- Verify .onion URLs show "Tor not ready" error
- Verify clearnet still works fine
6. Verify FIPS .fips URLs still work (if FIPS is available)
7. Verify Tor and FIPS are enabled by default on fresh install

View File

@@ -0,0 +1,224 @@
# WebKit Website Data Isolation Per Nostr Identity
**Status: IMPLEMENTED (Phase 0 + Phase A, verified end-to-end).**
## Problem
When a user logs in, visits a page, logs out, then logs in as a different
identity and revisits the same page, the second user sees the first user's
data (session cookies, localStorage, cached responses, service workers).
## Root Cause
The browser uses the **default** [`WebKitWebContext`](src/main.c:977) and its
attached [`WebKitWebsiteDataManager`](src/main.c:993) for the entire process
lifetime. That single data manager holds, process-wide:
- HTTP disk + memory cache
- Cookies (the primary leak — session cookies identify you to web pages)
- localStorage / sessionStorage / IndexedDB / WebSQL
- Service worker registrations + offline app cache
- Favicon database ([`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003))
The logout / identity-switch paths only clear the Nostr signer and do **nothing**
to WebKit's data stores or to the already-loaded tabs:
- [`app_menu_logout_proxy`](src/main.c:186) — frees `g_state.signer`, blanks pubkey, returns.
- [`agent_logout`](src/agent_login.c:485) — `app_clear_signer()` + `nostr_bridge_set_signer(NULL, ...)`.
- [`agent_switch_identity`](src/agent_login.c:492) — frees old signer, logs in new, no tab reload.
Two compounding problems:
1. **Persistent WebKit data** survives the identity switch.
2. **Live tabs** keep user A's DOM/localStorage in memory; wiping the data
manager alone does not clear already-loaded pages.
The existing [`plans/per-user-profiles.md`](plans/per-user-profiles.md) isolates
the SQLite layer (bookmarks, history, session, Nostr events) but does **not**
cover WebKit's own storage — this plan closes that gap.
## Solution (chosen: per-user data manager + clear-on-switch safety net)
### Part A — Per-user `WebKitWebsiteDataManager`
Give each pubkey its own data manager rooted at
`~/.sovereign_browser/profiles/<pubkey_hex>/webkit/` so cookies, cache, storage,
service workers, and favicons persist per-user and never cross-contaminate.
WebKitGTK constraint: a `WebKitWebContext` is bound to **one**
`WebKitWebsiteDataManager` for its lifetime — you cannot swap the data manager
on an existing context. Two viable strategies:
**Strategy 1 (preferred): per-user `WebKitWebContext`.**
Create a fresh `webkit_web_context_new_with_website_data_manager(dm)` for each
login. All tabs/webviews are created from this context. On logout/switch,
destroy the old context (which tears down its webviews) and build a new one.
This is the cleanest isolation and matches how Epiphary/GNOME Web does
per-profile isolation.
**Strategy 2 (fallback): single context, manual clear + per-user dirs.**
Keep the default context but on each login call
`webkit_website_data_manager_clear(dm, WEBKIT_WEBSITE_DATA_ALL, 0, NULL, NULL, NULL)`
then point the cookie/cache/storage dirs at the per-user path. This is fragile
because the context caches some state internally and the favicon DB directory
is set once at context init.
Recommend **Strategy 1**. It requires reworking how the context is created and
how `tab_manager` / `nostr_bridge` / `tor_scheme` / `nostr_scheme` / `net_services`
reference it (they currently call `webkit_web_context_get_default()` or receive
the context at init), but it gives true isolation.
### Part B — Clear-on-switch safety net
Even with per-user data managers, the **live tabs** (and the agent chat sidebar
webview) hold user A's DOM/localStorage in memory. On any identity change:
1. Close all tabs: `tab_manager_close_all()` ([`tab_manager.c:3727`](src/tab_manager.c)).
2. Destroy the agent chat sidebar webview if present.
3. (Strategy 1) Destroy the old `WebKitWebContext` + data manager.
4. Build the new per-user context + data manager.
5. Re-register URI schemes (`sovereign://`, `nostr://`, `tor://`) on the new
context — they are per-context.
6. Re-init `nostr_bridge`, `nostr_scheme`, `tor_scheme`, `net_services` against
the new context.
7. Re-init `tab_manager` against the new context (or expose a
`tab_manager_set_context()`).
8. Restore the new user's session (`session_restore()`) or open the default
new-tab URL.
## Architecture
```mermaid
flowchart TD
A[Login complete - pubkey known] --> B[profile_ensure_dir pubkey]
B --> C[Build per-user WebKitWebsiteDataManager<br/>rooted at profiles/pubkey/webkit]
C --> D[Build per-user WebKitWebContext<br/>new_with_website_data_manager]
D --> E[Register sovereign/nostr/tor schemes on new ctx]
E --> F[Re-init nostr_bridge, net_services, tab_manager on new ctx]
F --> G[session_restore or open new-tab URL]
H[Logout / switch_identity] --> I[tab_manager_close_all]
I --> J[Destroy sidebar webview]
J --> K[Destroy old WebKitWebContext + data manager]
K --> L{switch or logout?}
L -->|switch| A
L -->|logout| M[Show login dialog / wait for agent login]
M --> A
```
## Implementation Steps
### 1. Profile helpers for WebKit data dir
In [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) add:
- `profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz)`
`~/.sovereign_browser/profiles/<pubkey>/webkit/`
- `profile_ensure_webkit_dir(const char *pubkey_hex)` — mkdir -p.
### 2. New module `src/web_context.c` / `src/web_context.h`
Centralize context + data manager construction so login/logout/switch share
one code path. Functions:
- `web_context_build_for_pubkey(const char *pubkey_hex)` → returns a new
`WebKitWebContext*` with a per-user `WebKitWebsiteDataManager` (cookies,
cache, local_storage, indexed_db, websql, offline_app_cache, service_worker
registrations, itp, hsts all pointed at the per-user webkit dir), favicon
DB enabled, TLS errors ignored, security manager configured
(sovereign/tor/file secure+local).
- `web_context_teardown(WebKitWebContext *ctx)` — unrefs context + data
manager, frees per-user state.
- Holds the current context pointer (`g_web_ctx`) so the rest of the codebase
can fetch it without `webkit_web_context_get_default()`.
### 3. Refactor scheme/bridge/service init to be re-runnable
Currently these are one-shot inits tied to the default context. Make them
accept a `WebKitWebContext*` and be safe to call again on a new context:
- [`nostr_bridge_register`](src/nostr_bridge.c) — already takes `ctx`; ensure
it can be called twice (track prior registration, disconnect old handlers).
- [`nostr_scheme_register`](src/nostr_scheme.c) — make it take `ctx` (currently
uses default) and idempotent.
- [`tor_scheme_register`](src/tor_scheme.c) — same.
- [`net_services_init`](src/net_services.c) — currently calls
`webkit_web_context_get_default()` internally ([`net_services.c:69`](src/net_services.c));
pass `ctx` in instead.
### 4. Refactor `tab_manager` to support context swap
[`tab_manager_init`](src/tab_manager.h:46) currently stores the context once.
Add:
- `tab_manager_set_context(WebKitWebContext *ctx)` — updates the stored
context so subsequent `tab_manager_new_tab` calls use the new context.
Existing tabs are already closed (Part B step 1) before this is called.
- Or simpler: tear down and re-init `tab_manager` on each switch. Pick whichever
is less invasive given the static globals in [`tab_manager.c`](src/tab_manager.c).
### 5. Wire the login/logout/switch flows
- [`app_menu_logout_proxy`](src/main.c:186): after clearing the signer, run the
teardown sequence (close all tabs, destroy sidebar, destroy context) then
re-show the login dialog. On successful login, run the build sequence.
- [`agent_logout`](src/agent_login.c:485): same teardown, then leave the
browser in a logged-out state (no context, or a minimal ephemeral context
showing a "logged out" page).
- [`agent_switch_identity`](src/agent_login.c:492): teardown old, build new
with the new pubkey, restore session.
- Initial startup in [`main.c`](src/main.c:974): replace
`webkit_web_context_get_default()` with `web_context_build_for_pubkey()`
after login completes (this means deferring context creation until after
the login dialog — reordering the startup sequence).
### 6. Favicon database per user
[`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003)
currently uses the default shared location. In `web_context_build_for_pubkey`,
pass the per-user webkit dir (or a `favicons/` subdir) so favicons don't leak
between users.
### 7. Read-only / no-login mode
When running with `--no-login` or read-only, there is no pubkey to key the
data dir on. Use an ephemeral data manager
(`webkit_website_data_manager_new_ephemeral()`) so no data persists at all,
or a shared `~/.sovereign_browser/webkit-anon/` dir. Decide and document.
### 8. Migration
Existing users have data in WebKit's default location
(`~/.cache/sovereign_browser/` or similar). On first login with the new
system, optionally copy the default WebKit data dir into
`profiles/<pubkey>/webkit/` so they keep their cookies/cache. Low priority —
can ship without migration and just start fresh per user.
### 9. Tests
- Manual: log in as user A, visit a site that sets a cookie/localStorage,
log out, log in as user B, visit the same site, confirm user A's data is
gone and user B gets a fresh session.
- Add a test page under [`tests/local-site/`](tests/local-site) that writes
a visible marker to localStorage + a cookie, so this is reproducible.
- Verify `sovereign://`, `nostr://`, `tor://` schemes still work after a
switch (they are re-registered on the new context).
- Verify the agent chat sidebar works after a switch (its webview is rebuilt
on the new context).
## Files to Modify
- New: `src/web_context.c`, `src/web_context.h`
- [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) — webkit dir helpers
- [`src/main.c`](src/main.c) — startup ordering, logout proxy, use `web_context_*`
- [`src/agent_login.c`](src/agent_login.c) — `agent_logout`, `agent_switch_identity`
- [`src/tab_manager.c`](src/tab_manager.c) / [`src/tab_manager.h`](src/tab_manager.h) — context swap support, sidebar teardown
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — re-runnable registration
- [`src/nostr_scheme.c`](src/nostr_scheme.c) — accept ctx, idempotent
- [`src/tor_scheme.c`](src/tor_scheme.c) — accept ctx, idempotent
- [`src/net_services.c`](src/net_services.c) — accept ctx instead of default
- [`Makefile`](Makefile) — add `web_context.o`
- New test page: `tests/local-site/identity-leak-test.html`
## Risk Assessment
- **High risk** — changes how the `WebKitWebContext` is created and tears down,
which touches every subsystem that references the context.
- **Startup reordering** — context creation must move to *after* login, which
changes the long-standing flow in [`main.c`](src/main.c).
- **Re-runnable scheme registration** — WebKit may not support unregistering
scheme handlers cleanly; verify that building a fresh context and
re-registering works without leaking the old context.
- **Sidebar webview** — the agent chat sidebar ([`tab_manager_toggle_sidebar`](src/tab_manager.h:198))
keeps a long-lived webview; must be destroyed on context swap or it will
hold a ref to the dead context.
- **Mitigation**: implement Part B (clear-on-switch) first as a standalone
change — it alone fixes the leak even without per-user dirs. Ship that,
then layer Part A (per-user data managers) on top.

View File

@@ -20,6 +20,7 @@
* We access it via extern functions that main.c provides.
*/
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly);
extern void app_clear_signer(void);
extern nostr_signer_t *app_get_signer(void);
@@ -27,6 +28,12 @@ extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* Defined in main.c. Tears down the current user's web state (closes all
* tabs, destroys sidebar webviews, wipes WebKit cookies/cache/localStorage/
* service workers/favicons) so the next identity starts clean. See
* plans/webkit-data-isolation.md. */
extern void identity_teardown_web_state(void);
/* Track whether the agent (not the GTK dialog) performed the login. */
static gboolean g_agent_performed_login = FALSE;
@@ -131,13 +138,18 @@ static cJSON *login_local(cJSON *params) {
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
return make_error("DERIVE_FAILED", "Failed to derive public key");
}
char privkey_hex_out[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex_out[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
@@ -169,13 +181,18 @@ static cJSON *login_generate(cJSON *params) {
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
nsec[0] = '\0';
}
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
@@ -206,13 +223,18 @@ static cJSON *login_seed(cJSON *params) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
@@ -254,8 +276,8 @@ static cJSON *login_readonly(cJSON *params) {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* Read-only: no signer created. */
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
/* Read-only: no signer created. No privkey available. */
app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE);
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
@@ -290,7 +312,13 @@ static cJSON *login_nip46(cJSON *params) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
/* NIP-46: the client_privkey is a throwaway session key for the bunker
* protocol, not the user's identity key. The user's actual signing key
* lives on the remote bunker. We do NOT use it for bookmark HMAC d tags
* (the bunker signer handles NIP-44 content encryption; d-tag derivation
* would require the user's real privkey, which we don't have). Bookmarks
* can still be loaded from the db in read-only fashion. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
@@ -357,6 +385,12 @@ static cJSON *login_nsigner(cJSON *params) {
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
}
/* n_signer's serial/TCP transports require a kind-27235 auth envelope on
* every request. Install the default caller identity (matching n_signer's
* webserial demo) before issuing any verbs. Without this the device
* rejects the call with an auth error that surfaces as "code -310". */
key_store_nsigner_set_default_auth(signer);
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
@@ -367,14 +401,37 @@ static cJSON *login_nsigner(cJSON *params) {
char pubkey_hex[65];
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
const char *err_str = nostr_strerror(rc);
g_printerr("[agent-login] n_signer get_public_key failed: rc=%d (%s)\n",
rc, err_str ? err_str : "?");
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
if (rc == NOSTR_ERROR_NIP46_AUTH_CHALLENGE) {
/* Device RPC codes 2010-2017: auth-related rejection. Most
* commonly the kind-27235 caller auth envelope was missing or
* denied (the default caller identity is installed automatically,
* but the device's policy may still deny it). Can also mean the
* device is requesting on-device approval. */
return make_error("NSIGNER_AUTH_REJECTED",
"n_signer auth rejected (code -310). The caller "
"auth envelope was missing or denied, or the "
"device is requesting on-device approval. Confirm "
"any prompt on the hardware signer, then retry.");
}
{
char msg[256];
snprintf(msg, sizeof(msg),
"Failed to get pubkey from n_signer (rc=%d: %s).",
rc, err_str ? err_str : "unknown");
return make_error("NSIGNER_PUBKEY", msg);
}
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
/* n_signer: the privkey never leaves the hardware device, so we can't
* derive HMAC d tags client-side. Bookmarks fall back to read-only. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
@@ -459,6 +516,13 @@ cJSON *agent_login(cJSON *params) {
}
cJSON *agent_logout(void) {
/* Tear down the current user's web state (closes all tabs, destroys
* sidebar webviews, wipes WebKit cookies/cache/localStorage/service
* workers/favicons) so the next login starts with a clean web
* session. Must happen before clearing the signer. See
* plans/webkit-data-isolation.md. */
identity_teardown_web_state();
app_clear_signer();
nostr_bridge_set_signer(NULL, "", TRUE);
g_print("[agent-login] Logged out\n");
@@ -466,6 +530,12 @@ cJSON *agent_logout(void) {
}
cJSON *agent_switch_identity(cJSON *params) {
/* Tear down the previous user's web state BEFORE freeing the signer
* and logging in the new identity, so the new user starts with a
* clean web session (no leftover cookies/localStorage/tabs from the
* previous user). See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
/* Free the old signer first. */
app_clear_signer();
/* Then login with the new identity. */

View File

@@ -159,6 +159,10 @@ const mcp_tool_def_t tool_defs[] = {
"Reload the current page (bypassing cache).",
"{\"type\":\"object\",\"properties\":{}}"},
{"stop",
"Stop loading the current page. Mirrors the toolbar stop button.",
"{\"type\":\"object\",\"properties\":{}}"},
{"get_url",
"Get the current URL of the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
@@ -570,6 +574,19 @@ const mcp_tool_def_t tool_defs[] = {
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
/* Process / per-tab diagnostics (sovereign://processes) */
{"processes.list",
"List all sovereign_browser processes (main, WebKit renderers with their hosted tabs, WebKit network/gpu/storage, managed Tor/FIPS) with CPU%, RSS/PSS/USS, threads, uptime, I/O, FD count, and service state. CPU% is from /proc utime+stime deltas — the first call returns 0 for all processes (no baseline); poll ~1s for accurate values. Use this to find which WebProcess is hot.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tabs",
"List all open tabs with their per-tab performance probe (cpu_busy_percent from long-task sum, fps, active timer count, net in-flight, heap used, event listener count, worker count, DOM node count) and WebProcess PID. Sort by cpu_busy_percent to find the tab hogging CPU within a shared WebProcess. Internal sovereign:// tabs have null probes.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tab_probe",
"Get the full drill-down probe for a single tab: long-task timeline (last 30s), top long-task sources (script URLs + total ms), top active timers (code + interval + count), top network endpoints, heap, workers, DOM nodes. Use after processes.tabs identifies a hot tab to find out WHAT it is doing.",
"{\"type\":\"object\",\"properties\":{\"tab_index\":{\"type\":\"integer\",\"description\":\"Tab index from processes.tabs\"}},\"required\":[\"tab_index\"]}"},
};
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));

View File

@@ -14,10 +14,50 @@
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
/* Forward declarations from agent_tools.c */
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
/* ── Discovery file ───────────────────────────────────────────────── *
* When the configured port (default 17777) is already in use by another
* browser instance, we fall back to an OS-assigned port (bind to 0). So
* that external clients and browser.sh can find the actual port, we write
* a per-instance discovery file at /tmp/sovereign_browser_<pid>.port
* containing the PID and port. browser.sh globs these files to enumerate
* running instances. The file is removed on clean shutdown. */
static char g_discovery_path[128] = {0};
static void build_discovery_path(char *out, size_t out_sz) {
snprintf(out, out_sz, "/tmp/sovereign_browser_%d.port", (int)getpid());
}
static void write_discovery_file(int port) {
char path[128];
FILE *fp;
build_discovery_path(path, sizeof(path));
fp = fopen(path, "w");
if (fp == NULL) {
g_printerr("[agent] Failed to write discovery file %s\n", path);
return;
}
/* Format: "PID PORT\n" — easy to parse from shell with `read`. */
fprintf(fp, "%d %d\n", (int)getpid(), port);
fclose(fp);
snprintf(g_discovery_path, sizeof(g_discovery_path), "%s", path);
g_print("[agent] Discovery file: %s (pid=%d port=%d)\n",
path, (int)getpid(), port);
}
static void remove_discovery_file(void) {
if (g_discovery_path[0] != '\0') {
unlink(g_discovery_path);
g_discovery_path[0] = '\0';
}
}
/* ── Static state ─────────────────────────────────────────────────── */
static SoupServer *g_server = NULL;
@@ -210,8 +250,20 @@ int agent_server_start(int port) {
/* Add MCP handler at /mcp. */
agent_mcp_register(g_server);
/* Listen on the specified port (0 = auto-assign). */
/* Listen on the specified port. If it's already in use (another browser
* instance is running), fall back to port 0 so the OS picks a free port.
* The actual bound port is written to a discovery file so browser.sh and
* external MCP clients can find it. */
soup_server_listen_local(g_server, port, 0, &error);
if (error != NULL) {
if (port != 0) {
g_printerr("[agent] Port %d in use (%s) — falling back to OS auto-assign.\n",
port, error->message);
g_error_free(error);
error = NULL;
soup_server_listen_local(g_server, 0, 0, &error);
}
}
if (error != NULL) {
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
g_error_free(error);
@@ -233,6 +285,11 @@ int agent_server_start(int port) {
g_running = TRUE;
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
if (g_port != port) {
g_print("[agent] Note: using auto-assigned port %d (configured port %d was in use).\n",
g_port, port);
}
write_discovery_file(g_port);
return 0;
}
@@ -258,6 +315,7 @@ void agent_server_stop(void) {
g_running = FALSE;
g_port = 0;
remove_discovery_file();
g_print("[agent] Server stopped\n");
}

View File

@@ -16,6 +16,7 @@
#include "settings.h"
#include "search.h"
#include "nostr_url.h"
#include "process_info.h"
#include <string.h>
#include <stdlib.h>
@@ -510,7 +511,8 @@ static cJSON *tool_open(cJSON *params) {
typedef enum {
WEBVIEW_ACTION_BACK,
WEBVIEW_ACTION_FORWARD,
WEBVIEW_ACTION_RELOAD
WEBVIEW_ACTION_RELOAD,
WEBVIEW_ACTION_STOP
} webview_action_t;
typedef struct {
@@ -533,6 +535,9 @@ static gboolean webview_action_idle(gpointer user_data) {
case WEBVIEW_ACTION_RELOAD:
webkit_web_view_reload_bypass_cache(ctx->webview);
break;
case WEBVIEW_ACTION_STOP:
webkit_web_view_stop_loading(ctx->webview);
break;
}
}
g_free(ctx);
@@ -572,6 +577,20 @@ static cJSON *tool_reload(cJSON *params) {
return make_success(NULL);
}
/* Stop loading the active tab. Mirrors the toolbar stop button and the
* hamburger menu's Stop action. Useful for testing the per-tab
* reload/stop button state machine. */
static cJSON *tool_stop(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_STOP;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
static cJSON *tool_get_url(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
@@ -4104,6 +4123,7 @@ static tool_entry_t tool_table[] = {
{"back", tool_back, TRUE},
{"forward", tool_forward, TRUE},
{"reload", tool_reload, TRUE},
{"stop", tool_stop, TRUE},
{"get_url", tool_get_url, TRUE},
{"get_title", tool_get_title, TRUE},
@@ -4256,6 +4276,30 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return agent_fs_tools_dispatch(tool_name, params);
}
/* ── Process / per-tab diagnostics (work before login) ────────── *
* These read /proc and the in-memory probe store; they don't touch
* Nostr or page content, so they're safe before login. */
if (strcmp(tool_name, "processes.list") == 0) {
cJSON *arr = process_info_get_processes_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tabs") == 0) {
cJSON *arr = process_info_get_tabs_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tab_probe") == 0) {
int idx = get_int_param(params, "tab_index", -1);
if (idx < 0) {
return make_error("MISSING_PARAM",
"Provide 'tab_index' (from processes.tabs)");
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
return make_error("BAD_INDEX", "Tab index out of range");
}
return make_success(obj);
}
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,22 @@
/*
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
* and stored in the event's content field as a JSON tag array.
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
*
* Privacy design:
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
* 64-hex-char identifier. Relays learn nothing about folder names or
* structure (not even the folder count). Determinism preserves NIP-33
* replaceability: re-publishing the same path replaces the prior event.
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
* both use a random IV/nonce per call, which would break replaceability.
* - The real path and the bookmark list live inside the NIP-44 encrypted
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
* - The HMAC key is derived from the user's Nostr privkey:
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
* It is per-user, never published, and automatically available on every
* device the user logs in on.
*
* The bookmarks sync across all devices the user logs in on via the
* bootstrap relays.
@@ -22,10 +35,15 @@
extern "C" {
#endif
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
#define BOOKMARKS_MAX_PER_NODE 500
#define BOOKMARKS_NAME_MAX 128
#define BOOKMARKS_PATH_MAX 1024
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
* per application and can be rotated independently if ever needed. */
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
typedef struct {
char *url;
@@ -33,70 +51,101 @@ typedef struct {
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/* A node in the bookmark tree. The root node has name="" and path="".
* Each node corresponds to a folder path; bookmarks live at any node. */
typedef struct bookmark_node {
char *name; /* last path segment, "" for root */
char *path; /* full path, "" for root */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
/*
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
* database and decrypts them using the signer.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars)
* signer — the user's nostr_signer_t (NULL for read-only/no-login).
* The signer holds the private key; d tags are derived via
* nostr_signer_derive_hmac, so the privkey never lives in
* browser memory.
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
*
* Returns 0 on success, -1 on error.
*/
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
int bookmarks_init(nostr_signer_t *signer,
const char *pubkey_hex);
/* Free the in-memory bookmark list. Call at shutdown. */
/* Free the in-memory bookmark tree. Call at shutdown. */
void bookmarks_cleanup(void);
/* ── Read access ───────────────────────────────────────────────────── */
/* Get the list of directories (read-only). Returns a pointer to the
* internal array (valid until the next bookmarks operation). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get the root node of the bookmark tree (read-only). Returns NULL if
* bookmarks_init has not been called. The returned pointer is valid until
* the next bookmarks mutation. */
const bookmark_node_t *bookmarks_get_root(void);
/* Get a specific directory by name. Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* Find a node by path. Returns NULL if not found. */
const bookmark_node_t *bookmarks_find(const char *path);
/* ── Write access (requires signer) ────────────────────────────────── */
/* ── Write access (requires signer + privkey) ──────────────────────── */
/* Add a bookmark to a directory. If the directory doesn't exist, it's
* created. Updates the in-memory list, re-encrypts, publishes a new
* kind 30003 event for that directory, and stores in SQLite.
/* Add a bookmark to a folder path. If the folder (or any intermediate
* folder) doesn't exist, it is created. Updates the in-memory tree,
* re-encrypts, publishes a new kind 30003 event for that path, and stores
* in SQLite.
*
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
int bookmarks_add(const char *path, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
/* Remove a bookmark by URL from a folder path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
int bookmarks_remove(const char *path, const char *url);
/* Move a bookmark from one directory to another.
/* Rename a bookmark's title (the user-editable label) in place. The
* bookmark is located by (path, url); only its title changes. Re-encrypts
* and publishes a new kind 30003 event for that path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_rename(const char *path, const char *url, const char *new_title);
/* Move a bookmark from one folder to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
int bookmarks_move(const char *from_path, const char *url,
const char *to_path);
/* Create a new empty directory. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if directory already exists / error. */
int bookmarks_create_dir(const char *name);
/* Create a new empty folder at the given path. Parent folders are created
* if missing. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if the folder already exists / error. */
int bookmarks_create_dir(const char *path);
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
* to "General" before deletion. Publishes a kind 5 deletion event.
/* Rename a folder. Re-publishes the renamed node and every descendant with
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
* old d tags.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
int bookmarks_rename_dir(const char *old_path, const char *new_path);
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
* its descendants' bookmarks) are moved to "General" before deletion.
* Emits kind 5 deletion events for every d tag in the subtree.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *path, int move_to_general);
/* ── Change notification ───────────────────────────────────────────── */
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
* refresh every open tab. */
typedef void (*bookmarks_changed_cb)(void *user_data);
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
/* ── Internal (used by relay fetch) ────────────────────────────────── */
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called by bookmarks_init and after relay fetch. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Store a raw kind 30003 event in the database and decrypt its content
* into the in-memory list. Called by the relay fetch after login. */
* into the in-memory tree. Called by the relay fetch after login. */
int bookmarks_store_and_load_event(const void *event_cjson);
#ifdef __cplusplus

View File

@@ -182,6 +182,32 @@ char *fips_control_show_peers(int fd, char *error, size_t error_size) {
return result;
}
/* Generic single-command "show_*" that returns the data field as
* compact JSON. Used by show_tree and show_identity_cache, which have
* the same response shape as show_peers. */
static char *fips_control_show_generic(int fd, const char *command,
char *error, size_t error_size) {
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", command);
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return NULL;
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
char *result = printed ? g_strdup(printed) : NULL;
cJSON_free(printed);
cJSON_Delete(root);
return result;
}
char *fips_control_show_tree(int fd, char *error, size_t error_size) {
return fips_control_show_generic(fd, "show_tree", error, error_size);
}
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size) {
return fips_control_show_generic(fd, "show_identity_cache", error, error_size);
}
int fips_control_connect_peer(int fd, const char *npub, const char *address,
const char *transport,
char *error, size_t error_size) {
@@ -202,6 +228,23 @@ int fips_control_connect_peer(int fd, const char *npub, const char *address,
return 0;
}
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size) {
if (!npub || !npub[0]) {
set_error(error, error_size, "%s", "FIPS peer npub is required");
return -1;
}
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", "disconnect");
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
cJSON_AddStringToObject(params, "npub", npub);
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return -1;
cJSON_Delete(root);
return 0;
}
void fips_control_close(int *fd) {
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
}

View File

@@ -26,9 +26,14 @@ int fips_control_show_status(int fd, fips_status_t *status,
char *error, size_t error_size);
/* Returns the response data as compact JSON. Caller frees with g_free(). */
char *fips_control_show_peers(int fd, char *error, size_t error_size);
/* show_tree / show_identity_cache — same contract as show_peers. */
char *fips_control_show_tree(int fd, char *error, size_t error_size);
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
int fips_control_connect_peer(int fd, const char *npub, const char *address,
const char *transport,
char *error, size_t error_size);
const char *transport,
char *error, size_t error_size);
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size);
void fips_control_close(int *fd);
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms);

View File

@@ -29,6 +29,9 @@ void history_add_titled(const char *url, const char *title) {
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/rename", 28) == 0 ||
strncmp(url, "sovereign://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;

View File

@@ -84,6 +84,12 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
if (signer && identity->nsigner_index >= 0) {
nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index);
}
/* n_signer's serial and TCP transports require a kind-27235 auth
* envelope on every request. Install the default caller identity
* so the re-created signer can talk to the device. */
if (signer) {
key_store_nsigner_set_default_auth(signer);
}
return signer;
#else
return NULL;
@@ -95,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
}
}
/* ── n_signer default caller auth ─────────────────────────────────── *
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request. Without it the device rejects the call with an
* auth-related RPC error (codes 2010-2017) that the client maps to
* NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as
* "device requires approval" even though no on-device approval is actually
* needed.
*
* This installs the same fixed, well-known caller identity used by n_signer's
* own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side
* authentication credential only — it does NOT grant access to the device's
* mnemonic/keys; the device's own policy still decides which operations are
* allowed. No-op for non-nsigner backends.
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer) {
if (signer == NULL) {
return -1;
}
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
/* Match the webserial demo's caller privkey: bytes 1..32. */
static const unsigned char default_caller_priv[32] = {
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
};
return nostr_signer_nsigner_set_auth(signer, default_caller_priv,
"sovereign_browser");
#else
return -1;
#endif
}
/* ── Legacy file cleanup ──────────────────────────────────────────── */
int key_store_delete_legacy_file(void) {

View File

@@ -87,6 +87,24 @@ int key_store_save_profile_identity(const char *pubkey_hex,
int key_store_load_profile_identity(const char *pubkey_hex,
key_store_method_t *method_out);
/*
* Set a default caller auth identity on an n_signer remote signer.
*
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request (the device rejects unauthenticated calls with an
* auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE).
* This installs a fixed, well-known caller identity (the same one used by
* n_signer's own webserial demo) so the browser can talk to a USB-attached
* n_signer without the user having to configure caller credentials.
*
* Safe to call on any signer; no-op for non-nsigner backends. Call after
* the signer is created and before the first verb (e.g. before
* nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key).
*
* Returns 0 on success, non-zero on error (e.g. not an nsigner signer).
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer);
#ifdef __cplusplus
}
#endif

View File

@@ -12,6 +12,7 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include "version.h"
#include <string.h>
#include <stdlib.h>
@@ -111,6 +112,7 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx);
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
static void on_index_changed(GtkWidget *spin, gpointer user_data);
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
#endif
@@ -512,6 +514,13 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
return;
}
/* n_signer's serial/TCP transports require a kind-27235 auth envelope
* on every request. Install the default caller identity (matching
* n_signer's webserial demo) before issuing any verbs. Without this
* the device rejects the call with an auth error that surfaces as
* "code -310". */
key_store_nsigner_set_default_auth(signer);
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
if (rc_idx != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
@@ -524,19 +533,34 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
char pubkey_hex[65];
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc_pk != NOSTR_SUCCESS) {
char errmsg[256];
char errmsg[320];
const char *desc = "unknown error";
switch (rc_pk) {
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
case -2002: desc = "index not in signer's whitelist"; break;
case -3: desc = "crypto operation failed"; break;
case NOSTR_ERROR_NIP46_AUTH_CHALLENGE:
/* Device RPC codes 2010-2017: auth-related rejection.
* Most commonly this means the kind-27235 caller auth
* envelope was missing/rejected (the default caller
* identity is installed automatically, but the device's
* policy may still deny it). It can also mean the device
* is requesting on-device approval (button press / TUI
* confirm) for this operation. */
desc = "auth rejected by n_signer (code -310). The caller "
"auth envelope was missing or denied, or the device "
"is requesting on-device approval. Confirm any "
"prompt on the n_signer hardware, then click Sign "
"In again";
break;
default: break;
}
snprintf(errmsg, sizeof(errmsg),
"n_signer error: %s (code %d). Try a different key index.",
desc, rc_pk);
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return;
}
@@ -1016,17 +1040,24 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_signal_connect(transport_combo, "changed",
G_CALLBACK(on_transport_changed), box);
/* Nostr index spinner. */
/* Nostr index spinner. The label shows the derived BIP-32 path
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
* spin button value, so they can see which key they are selecting. */
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
/* Update the path label whenever the index value changes. */
g_signal_connect(index_spin, "value-changed",
G_CALLBACK(on_index_changed), index_label);
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
@@ -1040,9 +1071,23 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_object_set_data(G_OBJECT(box), "service-box", service_box);
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
g_object_set_data(G_OBJECT(box), "index-label", index_label);
return box;
}
/* Update the key-index path label when the spin button value changes.
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
* current N value substituted in. */
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
GtkWidget *label = GTK_WIDGET(user_data);
int idx = gtk_spin_button_get_value_as_int(sb);
char text[96];
snprintf(text, sizeof(text),
"Key Index (m/44'/1237'/%d'/0/0):", idx);
gtk_label_set_text(GTK_LABEL(label), text);
}
/* ── Serial device enumeration callback ───────────────────────── */
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
@@ -1092,7 +1137,7 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
/* Create dialog without auto-responding buttons — we add custom
* buttons so we control whether the dialog closes. */
GtkWidget *dialog = gtk_dialog_new();
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser — Sign In");
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser " SB_VERSION);
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);

View File

@@ -37,6 +37,7 @@
#include "login_dialog.h"
#include "nostr_bridge.h"
#include "nostr_scheme.h"
#include "tor_scheme.h"
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
@@ -54,6 +55,8 @@
#include "agent_conversations.h"
#include "agent_skills.h"
#include "net_services.h"
#include "webkit_data.h"
#include "web_context.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
@@ -65,21 +68,33 @@
typedef struct {
nostr_signer_t *signer; /* NULL for read-only mode */
char pubkey_hex[65];
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
key_store_method_t method;
gboolean readonly; /* TRUE if no signing available */
} app_state_t;
static app_state_t g_state = {0};
/* Forward declaration — defined before main(). */
/* Forward declarations — defined later in this file. */
static int switch_to_user_db(const char *pubkey_hex);
static int do_login(GtkWindow *parent);
static WebKitWebContext *build_context_for_current_user(void);
static char g_current_profile_db[512];
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
/* TRUE once main() has finished initial startup (tab_manager_init has
* run). Used by app_set_signer() to distinguish a first-time login
* (context built later by main()) from a runtime identity switch
* (context was torn down by identity_teardown_web_state and must be
* rebuilt here). */
static gboolean g_post_startup = FALSE;
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
@@ -88,6 +103,12 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
if (privkey_hex && privkey_hex[0]) {
strncpy(g_state.privkey_hex, privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
} else {
g_state.privkey_hex[0] = '\0';
}
g_logged_in = TRUE;
/* Update modules that hold a signer reference. */
@@ -103,6 +124,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* If this is a RUNTIME identity switch (past startup) and the
* previous context was torn down by identity_teardown_web_state(),
* rebuild a fresh per-user context for the new identity now. On the
* first login (before main() calls tab_manager_init), g_post_startup
* is FALSE and web_context_get() is NULL, so main() builds the
* context after login — we skip here to avoid a double-build. */
if (g_post_startup && web_context_get() == NULL) {
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user so they don't stare at
* an empty window after the teardown closed all tabs. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to rebuild web context\n");
}
}
}
void app_clear_signer(void) {
@@ -111,6 +149,7 @@ void app_clear_signer(void) {
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
@@ -130,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; }
* menu builder.
*/
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
* Called on logout and identity switch. Closes all tabs, destroys the
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
* with the previous user's sovereign://agents/chat session), and wipes
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
* service workers, favicons) from the shared WebKitWebsiteDataManager.
*
* Without this, the next user inherits the previous user's web session:
* cookies identify you to web pages, localStorage holds per-site state,
* and the live tabs keep the previous user's DOM in memory. See
* plans/webkit-data-isolation.md for the full rationale.
*
* This must run on the GTK main thread. It pumps a nested main loop
* briefly while webkit_website_data_manager_clear() completes.
*/
void identity_teardown_web_state(void) {
/* 1. Close all tabs. This destroys the webviews and drops their
* in-memory DOM/localStorage. Must happen before the context
* teardown so no webviews hold dangling references to the old
* context. Suppress the normal "last tab closed → quit the app"
* behavior so the browser stays alive for the next user; the
* caller opens a fresh tab after the switch. */
tab_manager_set_suppress_quit_on_last_tab(TRUE);
tab_manager_close_all();
tab_manager_set_suppress_quit_on_last_tab(FALSE);
/* 2. Destroy the agent chat sidebar webviews in every window. The
* sidebar webview is outside the notebook so tab_manager_close_all
* does not touch it; it would otherwise keep a reference to the
* old context (and the previous user's chat session) alive. */
tab_manager_destroy_sidebar_webviews();
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
* context and its per-user WebKitWebsiteDataManager. With Phase A
* isolation, each user has their own data manager rooted at
* profiles/<pubkey>/webkit/, so tearing down the context is what
* actually isolates the users — the next build creates a fresh
* data manager for the new user. The Phase 0 webkit_data_clear_all
* wipe is no longer needed for isolation (the data manager is
* per-user), but we keep it as a defense-in-depth safety net in
* case any process-global WebKit state survived the context
* teardown. */
WebKitWebContext *ctx = web_context_get();
if (ctx != NULL) {
/* Defense-in-depth: clear the data manager before tearing down
* the context. This catches any process-global caches that
* outlive the context. */
int rc = webkit_data_clear_all(ctx, FALSE);
if (rc != 0) {
g_printerr("[identity] webkit_data_clear_all returned %d "
"(continuing with context teardown)\n", rc);
}
web_context_teardown();
}
}
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
@@ -137,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Tear down the previous user's web state BEFORE installing the
* new signer, so the new user starts with a clean web session. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
@@ -156,6 +255,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
switch_to_user_db(g_state.pubkey_hex);
}
/* Build a fresh per-user WebKitWebContext for the new identity
* (with its own per-user data manager) and re-register the
* sovereign://, nostr://, tor:// schemes on it. The old context
* was torn down by identity_teardown_web_state() above. */
if (build_context_for_current_user() == NULL) {
g_printerr("[identity] Failed to build context for new user — "
"browser will have no web views\n");
}
/* Open a fresh tab for the new user so they don't stare at an
* empty window after the teardown closed all the previous user's
* tabs. Uses the new user's per-user new_tab_url setting. */
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
g_print("[identity] switched: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
@@ -175,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
GtkWindow *window = GTK_WINDOW(data);
/* Tear down the current user's web state (closes all tabs, destroys
* the sidebar webviews, wipes cookies/cache/localStorage/service
* workers/favicons) BEFORE clearing the signer, so the next login
* starts with a clean web session. See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
/* Reset the per-user db tracking so the next login re-opens the
* new user's browser.db rather than assuming we're already on it. */
g_current_profile_db[0] = '\0';
g_print("[identity] logged out\n");
/* Re-show the login dialog so the user (or agent) can log in as a
* different identity. The dialog runs a nested main loop, so the
* agent server stays live and an agent can log in via MCP while the
* dialog is showing. On success, do_login() installs the new signer
* and switch_to_user_db() opens the new user's profile. */
if (window != NULL) {
if (do_login(window) == 0) {
/* Build a fresh per-user WebKitWebContext for the new
* identity. The old context was torn down above. For
* --no-login mode this builds an ephemeral context. */
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to build context after re-login\n");
}
}
}
}
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
@@ -192,10 +342,46 @@ void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
void app_menu_fips_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data) {
if (g_object_get_data(G_OBJECT(item), "network-toggle-sync") != NULL) return;
net_service_type_t type = (net_service_type_t)GPOINTER_TO_INT(data);
if (type != NET_SERVICE_TOR && type != NET_SERVICE_FIPS) {
g_printerr("[menu.network] Invalid network service type %d\n", (int)type);
return;
}
const char *name = type == NET_SERVICE_TOR ? "Tor" : "FIPS";
gboolean enabled = gtk_check_menu_item_get_active(item);
browser_settings_t *settings = settings_get_mutable();
gboolean *setting = type == NET_SERVICE_TOR
? &settings->tor_enabled : &settings->fips_enabled;
*setting = enabled;
settings_save_user();
int rc = enabled ? net_service_enable(type) : net_service_disable(type);
if (enabled && rc != 0) {
const net_service_t *status = net_service_get_status(type);
g_printerr("[menu.network] Failed to enable %s service: %s; reverting preference\n",
name, status && status->error_msg
? status->error_msg : "synchronous startup failure");
*setting = FALSE;
settings_save_user();
g_object_set_data(G_OBJECT(item), "network-toggle-sync",
GINT_TO_POINTER(1));
gtk_check_menu_item_set_active(item, FALSE);
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
return;
}
if (rc != 0) {
g_printerr("[menu.network] Failed to disable %s service (preference remains disabled)\n",
name);
} else {
g_print("[menu.network] %s service %s; per-user preference saved\n",
name, enabled ? "enabled" : "disabled");
}
}
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
@@ -235,45 +421,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
/* Hamburger-menu items for sovereign:// internal pages always open in
* a new tab so the user's current page is preserved. */
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
} else {
/* No active tab — open a new one pointed at the settings page. */
tab_manager_new_tab("sovereign://settings");
}
tab_manager_new_tab("sovereign://settings");
}
void on_menu_profile(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
tab_manager_new_tab("sovereign://profile");
}
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
* the sovereign://agents internal page, which renders the agent provider
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
* internal page in a new tab. It renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://agents");
}
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");
}
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
* internal page in a new tab. It renders the FIPS mesh network
* status + management UI. */
void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://fips");
}
/* The hamburger-menu "Processes…" item opens the sovereign://processes
* internal page in a new tab. It renders the process list + per-tab
* performance diagnostics UI. */
void on_menu_processes(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://processes");
}
/* ---- Keyboard shortcuts --------------------------------------------- *
@@ -401,6 +587,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_OPEN_PROCESSES:
on_menu_processes(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
@@ -453,7 +643,83 @@ static void agent_login_callback(void) {
g_print("[login] Agent login detected.\n");
}
/* ---- Window destroy ------------------------------------------------- */
/* ---- Window close / destroy ----------------------------------------- *
* The main window uses a delete-event handler to intercept the window
* manager's close request BEFORE the window is destroyed. This lets us
* close the main window's tabs cleanly (updating g_tab_count) and keep
* the app running if auxiliary windows still have tabs. The app only
* quits when the last window is closed (no tabs remain anywhere).
*
* delete-event: runs first. Closes all main-window tabs. If aux windows
* still have tabs, returns TRUE to suppress destroy and hides the
* main window. If no tabs remain, returns FALSE to let destroy
* proceed → on_window_destroy → gtk_main_quit().
* destroy: runs only when the app is truly shutting down. Performs
* session save, net_services_shutdown, agent_server_stop, etc.
*/
/* Guard flag: set while on_window_delete_event is closing the main
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
* calls gtk_window_close(g_window) after the last tab is closed, which
* would re-emit delete-event. */
static gboolean g_main_window_closing = FALSE;
static gboolean on_window_delete_event(GtkWidget *widget,
GdkEvent *event,
gpointer data) {
(void)event;
(void)data;
/* If we're already in the process of closing (re-entrant call from
* tab_manager_close_tab → gtk_window_close), let the destroy
* proceed. */
if (g_main_window_closing) {
return FALSE;
}
g_main_window_closing = TRUE;
/* Close all tabs that belong to the main window's notebook. We
* identify main-window tabs by checking gtk_notebook_page_num on
* the main notebook. tab_manager_close_tab handles removing from
* the notebook and the g_tabs array. Re-scan each iteration because
* closing shifts indices. */
for (;;) {
gboolean found = FALSE;
GtkWidget *main_nb = tab_manager_get_main_notebook();
if (main_nb == NULL) break;
/* Close from the highest index down so removal doesn't shift
* unprocessed indices. Find the highest-index tab in the main
* notebook and close it. */
for (int i = tab_manager_count() - 1; i >= 0; i--) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL || tab->page == NULL) continue;
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
tab->page) >= 0) {
tab_manager_close_tab(i);
found = TRUE;
break;
}
}
if (!found) break;
}
/* If auxiliary windows still have tabs, keep the app running.
* Suppress the default destroy by returning TRUE, and hide the
* main window. The aux windows continue independently. */
if (tab_manager_count() > 0) {
g_print("[windows] Main window closed but %d tab(s) remain in "
"other window(s) — keeping app alive.\n",
tab_manager_count());
gtk_widget_hide(widget);
g_main_window_closing = FALSE; /* allow re-opening later */
return TRUE; /* suppress destroy */
}
/* No tabs left anywhere — let the destroy proceed, which triggers
* on_window_destroy and quits the app. Keep the guard set so any
* re-entrant delete-event from the destroy path doesn't re-enter. */
return FALSE;
}
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
@@ -521,6 +787,10 @@ static int do_login(GtkWindow *parent) {
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
result.method == KEY_STORE_METHOD_NONE);
g_logged_in = TRUE;
@@ -549,9 +819,9 @@ static int do_login(GtkWindow *parent) {
/* Track the currently-open per-user db path so we can skip re-opening
* the same database (e.g. when app_set_signer() is called during the
* login dialog and then main() calls switch_to_user_db() again). */
static char g_current_profile_db[512] = "";
* login dialog and then main() calls switch_to_user_db() again).
* The actual definition is near the top of this file (forward-declared
* before the menu proxies that reset it on logout). */
static int switch_to_user_db(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[profile] No pubkey, staying on global.db\n");
@@ -606,6 +876,52 @@ static int switch_to_user_db(const char *pubkey_hex) {
return 0;
}
/* ---- Per-user WebKit context build helper --------------------------- *
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
* and updates tab_manager so new tabs use the new context. Called from:
* - main() after login, before creating tabs
* - app_menu_switch_identity_proxy() after tearing down the old context
* - app_menu_logout_proxy() after re-login
*
* The caller must have torn down the previous context (via
* web_context_teardown()) and closed all tabs/sidebar webviews first.
* Returns the new context, or NULL on failure (the caller may fall back
* to a blank window or exit).
*/
static WebKitWebContext *build_context_for_current_user(void) {
WebKitWebContext *web_ctx;
if (g_state.pubkey_hex[0] != '\0') {
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
} else {
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
* persistence. See plans/webkit-data-isolation.md. */
web_ctx = web_context_build_ephemeral();
}
if (web_ctx == NULL) {
g_printerr("[main] Failed to build WebKit context\n");
return NULL;
}
/* Register the sovereign:// URI scheme for the window.nostr bridge.
* nostr_bridge_set_signer() was already called by the login path,
* so the bridge will use the current signer. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Register nostr:// and tor:// entity-page schemes on this context.
* These are per-context registrations, so they must be re-registered
* on every fresh context. */
nostr_scheme_register(web_ctx);
tor_scheme_register(web_ctx);
/* Point tab_manager at the new context so subsequent new tabs (and
* the sidebar webview) are created from it. */
tab_manager_set_context(web_ctx);
return web_ctx;
}
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
@@ -709,6 +1025,8 @@ int main(int argc, char **argv) {
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "delete-event",
G_CALLBACK(on_window_delete_event), NULL);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
@@ -838,48 +1156,35 @@ int main(int argc, char **argv) {
* when the relay fetch completes and the kind 0 event is stored. */
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */
WebKitWebContext *web_ctx = webkit_web_context_get_default();
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
* Build a fresh WebKitWebContext with a per-user
* WebKitWebsiteDataManager rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
* data manager for --no-login mode). This gives true per-identity
* isolation of cookies, cache, localStorage, IndexedDB, service
* workers, and favicons. The helper also registers the sovereign://,
* nostr://, tor:// URI schemes on the new context and points
* tab_manager at it. */
WebKitWebContext *web_ctx = build_context_for_current_user();
if (web_ctx == NULL) {
g_printerr("[main] Cannot continue without a WebKit context.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
/* ── Security strip: disable web security restrictions ─────── */
/* Fetch the security manager for the new context — used below to
* wire the sovereign://security page to the first tab's settings. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
/* Register sovereign:// as secure only. Do NOT register it as "local"
* (WebKit blocks fetch from https to local origins) and do NOT register
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
* which we can't set with the basic finish API). Without these, WebKit
* treats sovereign:// as a simple secure scheme with no CORS checks. */
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Enable the favicon database so WebKitGTK automatically fetches and
* caches favicons for visited pages. Without this, the
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
* always returns NULL. Passing NULL for the directory uses WebKit's
* default cache location. */
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
g_print("[main] Favicon database enabled\n");
/* Initialize browser-managed/attached network services after login and
* after the shared WebKit context/data manager have been configured. */
/* Initialize browser-managed/attached network services after the
* per-user context has been built. net_services_refresh_proxy()
* now uses web_context_get() so proxy settings apply to the
* per-user context. */
net_services_init();
/* 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);
@@ -887,6 +1192,11 @@ int main(int argc, char **argv) {
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
/* Mark startup complete. From now on, app_set_signer() (called by
* agent login / switch_identity) will rebuild the per-user web
* context if it was torn down, since main() won't build it again. */
g_post_startup = TRUE;
/* Decide whether to restore the previous session or open CLI URLs.
*
* Precedence:

View File

@@ -8,6 +8,7 @@
#include "tor_control.h"
#include "fips_control.h"
#include "settings.h"
#include "web_context.h"
#include <webkit2/webkit2.h>
@@ -66,31 +67,17 @@ static int ensure_private_dir(const char *path, char *error, size_t error_size)
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();
/* Use the per-user context (web_context_get) instead of the default
* context, so proxy settings apply to the active user's context.
* Falls back to the default context if no per-user context has been
* built yet (e.g. during early startup). See plans/webkit-data-isolation.md. */
WebKitWebContext *ctx = web_context_get();
if (ctx == NULL) ctx = webkit_web_context_get_default();
if (ctx == NULL) return;
WebKitWebsiteDataManager *dm = webkit_web_context_get_website_data_manager(ctx);
if (!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);
if (dm == NULL) return;
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)" : "");
dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL);
}
static gboolean pipe_log_cb(GIOChannel *channel, GIOCondition condition, gpointer data) {

File diff suppressed because it is too large Load Diff

View File

@@ -128,6 +128,39 @@ static const char *NOSTR_SHIM_JS =
" }\n"
"})();\n";
/* Wheel-scroll workaround for WebKitGTK.
*
* On some pages (e.g. jumble.social) WebKitGTK dispatches JS wheel events
* but does not perform the native scroll action — so the scrollbar works
* (native GDK scroll) but the mouse wheel doesn't scroll the page. This
* script installs a non-passive wheel listener on window that, when the
* page's main scroll container is the document (the common case), calls
* preventDefault() and manually scrolls by the wheel delta. It only
* intervenes when the target is the document/body/html or an element with
* no own scroll (overflow: visible), so nested scroll containers (which
* WebKitGTK handles correctly) are not broken. */
static const char *WHEEL_SCROLL_JS =
"(function(){\n"
" 'use strict';\n"
" function isMainScrollTarget(el){\n"
" if(!el) return false;\n"
" if(el===document||el===document.documentElement||el===document.body) return true;\n"
" var cs=getComputedStyle(el);\n"
" return (cs.overflowY==='visible'||cs.overflowY==='') && el.scrollHeight<=el.clientHeight;\n"
" }\n"
" window.addEventListener('wheel',function(e){\n"
" if(e.defaultPrevented) return;\n"
" if(!isMainScrollTarget(e.target)) return;\n"
" var html=document.documentElement;\n"
" if(html.scrollHeight<=html.clientHeight) return;\n"
" e.preventDefault();\n"
" var dy=e.deltaY;\n"
" if(e.deltaMode===1) dy*=40;\n"
" else if(e.deltaMode===2) dy*=html.clientHeight;\n"
" window.scrollBy(0,dy);\n"
" },{capture:false,passive:false});\n"
"})();\n";
void nostr_inject_setup(WebKitWebView *webview) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
@@ -148,5 +181,14 @@ void nostr_inject_setup(WebKitWebView *webview) {
/* The manager takes ownership of the script. */
webkit_user_script_unref(script);
g_print("[inject] window.nostr shim installed\n");
/* Wheel-scroll workaround — see WHEEL_SCROLL_JS comment above. */
WebKitUserScript *wheel = webkit_user_script_new(
WHEEL_SCROLL_JS,
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, wheel);
webkit_user_script_unref(wheel);
}

101
src/perf_probe.c Normal file
View File

@@ -0,0 +1,101 @@
/*
* perf_probe.c — per-tab performance probe injection
*
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
* and is embedded into the binary by embed_web_files.sh. This module
* wraps it with a per-tab preamble that injects a
* <meta name="sb-tab-index" content="N"> tag into the document before
* the probe runs, so the probe can identify which tab it is reporting
* for.
*
* The preamble also short-circuits on sovereign:// pages (the probe
* would be self-referential and noisy on internal pages). We can't
* filter by URL at injection time because WebKitUserScript is added
* once per webview and applies to all future loads, so the URL check
* happens at runtime inside the preamble.
*/
#include "perf_probe.h"
#include "embedded_web_content.h"
#include <string.h>
#include <stdio.h>
/* The preamble runs at document_start, before perf-probe.js. It:
* 1. Checks the current URL — if it's a sovereign:// internal page,
* sets a flag so the probe body no-ops.
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
* probe (which runs at document_start as well, but after this
* preamble in script-order) can read its tab index.
*
* The tab index is baked in at injection time as a literal. */
static char *build_preamble(int tab_index) {
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
char *p = g_malloc(512);
if (p == NULL) return NULL;
snprintf(p, 512,
"(function(){\n"
" 'use strict';\n"
" try {\n"
" var u = location.href || '';\n"
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
" window.__sbPerfProbeSkip = true;\n"
" } else {\n"
" window.__sbPerfProbeSkip = false;\n"
" window.__sbPerfTabIndex = %d;\n"
" }\n"
" } catch(e) {}\n"
"})();\n",
tab_index);
return p;
}
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
if (manager == NULL) {
g_printerr("[perf-probe] No user content manager — probe not injected\n");
return;
}
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
char *preamble = build_preamble(tab_index);
if (preamble == NULL) return;
WebKitUserScript *pre_script = webkit_user_script_new(
preamble,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL, /* allow list (NULL = all) */
NULL /* block list */
);
webkit_user_content_manager_add_script(manager, pre_script);
webkit_user_script_unref(pre_script);
g_free(preamble);
/* The probe body itself, from the embedded www/js/perf-probe.js. */
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
if (f == NULL) {
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
return;
}
/* webkit_user_script_new needs a NUL-terminated string. The embedded
* data is a byte array of known size; copy it into a NUL-terminated
* buffer. */
char *probe_js = g_malloc(f->size + 1);
if (probe_js == NULL) return;
memcpy(probe_js, f->data, f->size);
probe_js[f->size] = '\0';
WebKitUserScript *probe_script = webkit_user_script_new(
probe_js,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, probe_script);
webkit_user_script_unref(probe_script);
g_free(probe_js);
}

36
src/perf_probe.h Normal file
View File

@@ -0,0 +1,36 @@
/*
* perf_probe.h — per-tab performance probe injection
*
* Injects www/js/perf-probe.js into a webview via
* WebKitUserContentManager, prefixed with a tiny preamble that sets
* the tab index (so the probe knows which tab it is reporting for).
*
* The probe is skipped on sovereign:// internal pages to avoid
* self-noise and recursion. Call once per webview at tab creation,
* after nostr_inject_setup().
*/
#ifndef PERF_PROBE_H
#define PERF_PROBE_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Inject the perf probe into the given webview, tagged with the given
* tab index. The index is baked into a preamble that runs before the
* probe script and sets window.__sbPerfTabIndex.
*
* Safe to call multiple times on the same webview (the probe guards
* against double-injection).
*/
void perf_probe_setup(WebKitWebView *webview, int tab_index);
#ifdef __cplusplus
}
#endif
#endif /* PERF_PROBE_H */

558
src/process_info.c Normal file
View File

@@ -0,0 +1,558 @@
/*
* process_info.c — process and per-tab performance diagnostics
*
* See process_info.h for the two-layer design. This file implements:
*
* Layer 1 — /proc enumeration for the main PID, WebKit child PIDs
* (discovered via webkit_web_view_get_process_id() per tab
* plus a PPID scan for network/gpu/storage processes), and
* managed Tor/FIPS PIDs from net_services. CPU% is computed
* from utime+stime deltas between successive calls.
*
* Layer 2 — storage of the most recent perf-probe.js report per tab
* index, exposed to the UI and MCP tools.
*
* All /proc parsing is Linux-specific and uses only libc + glib.
*/
#include "process_info.h"
#include "tab_manager.h"
#include "net_services.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <dirent.h>
#include <ctype.h>
#include <sys/types.h>
#include <glib.h>
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
typedef struct {
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
} cpu_sample_t;
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
static cpu_sample_t *cpu_sample_get(guint pid) {
if (g_cpu_samples == NULL) {
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
NULL, g_free);
}
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
if (s == NULL) {
s = g_new0(cpu_sample_t, 1);
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
}
return s;
}
static long g_clk_tck = 0;
static long clk_tck(void) {
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
return g_clk_tck > 0 ? g_clk_tck : 100;
}
/* ── /proc readers ──────────────────────────────────────────────────── */
/* Read the first line of a /proc file into buf (NUL-terminated). */
static int read_proc_file(const char *path, char *buf, size_t buflen) {
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
size_t n = fread(buf, 1, buflen - 1, f);
buf[n] = '\0';
fclose(f);
return (int)n;
}
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
* (2) comm (3) state (4) ppid (14) utime (15) stime
* (22) starttime (in clock ticks since boot)
* comm is wrapped in parens and may contain spaces, so we parse by
* finding the last ')' and tokenizing after it. */
static int parse_stat(guint pid, char *state_out, size_t state_sz,
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
char buf[4096];
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
/* comm: between first '(' and last ')'. */
char *lp = strchr(buf, '(');
char *rp = strrchr(buf, ')');
if (lp == NULL || rp == NULL || rp <= lp) return -1;
size_t comm_len = (size_t)(rp - lp - 1);
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
memcpy(comm_out, lp + 1, comm_len);
comm_out[comm_len] = '\0';
/* The rest after ") " is space-separated fields starting at field 3. */
char *rest = rp + 2;
/* rest = "state ppid ... " — tokenize. */
char *save = NULL;
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
if (tok == NULL) return -1;
if (state_out) {
size_t sl = strlen(tok);
if (sl >= state_sz) sl = state_sz - 1;
memcpy(state_out, tok, sl);
state_out[sl] = '\0';
}
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
if (tok == NULL) return -1;
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
* majflt, cmajflt. Skip them. */
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
if (tok == NULL) return -1;
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
if (tok == NULL) return -1;
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
if (tok == NULL) return -1;
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
return 0;
}
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
* the integer value in KB, or -1 if not found. */
static long status_field_kb(guint pid, const char *field) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/status", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long val = -1;
size_t flen = strlen(field);
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, field, flen) == 0) {
/* e.g. "VmRSS: 12345 kB" */
char *p = line + flen;
while (*p == ' ' || *p == '\t') p++;
val = strtol(p, NULL, 10);
break;
}
}
fclose(f);
return val;
}
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
* Returns 0 on success. */
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long pss = -1, priv_clean = 0, priv_dirty = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "Pss:", 4) == 0) {
pss = strtol(line + 4, NULL, 10);
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
priv_clean = strtol(line + 14, NULL, 10);
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
priv_dirty = strtol(line + 14, NULL, 10);
}
}
fclose(f);
if (pss_kb) *pss_kb = pss;
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
return (pss < 0) ? -1 : 0;
}
/* Read /proc/<pid>/io fields. Returns 0 on success. */
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/io", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long rb = 0, wb = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "read_bytes:", 11) == 0) {
rb = strtoll(line + 11, NULL, 10);
} else if (strncmp(line, "write_bytes:", 12) == 0) {
wb = strtoll(line + 12, NULL, 10);
}
}
fclose(f);
if (read_bytes) *read_bytes = rb;
if (write_bytes) *write_bytes = wb;
return 0;
}
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
static void read_cmdline(guint pid, char *buf, size_t buflen) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
FILE *f = fopen(path, "r");
if (f == NULL) { buf[0] = '\0'; return; }
size_t n = fread(buf, 1, buflen - 1, f);
fclose(f);
if (n == 0) { buf[0] = '\0'; return; }
buf[n] = '\0';
/* Replace NUL separators with spaces. */
for (size_t i = 0; i < n; i++) {
if (buf[i] == '\0') buf[i] = ' ';
}
/* Trim trailing space. */
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
}
/* Count entries in a /proc directory (used for threads and fds). */
static int count_dir_entries(const char *path) {
DIR *d = opendir(path);
if (d == NULL) return -1;
int count = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.') continue;
count++;
}
closedir(d);
return count;
}
/* Read /proc/stat's btime (boot time in seconds since epoch). */
static time_t g_btime = 0;
static time_t boot_time(void) {
if (g_btime != 0) return g_btime;
FILE *f = fopen("/proc/stat", "r");
if (f == NULL) return 0;
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "btime", 5) == 0) {
g_btime = (time_t)strtoll(line + 5, NULL, 10);
break;
}
}
fclose(f);
return g_btime;
}
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
typedef struct {
cJSON *probe; /* most recent probe report (owned) */
time_t ts; /* when recorded */
} probe_slot_t;
/* Sparse array indexed by tab index. Grows as needed. */
static probe_slot_t *g_probes = NULL;
static int g_probe_cap = 0;
static void probe_ensure(int idx) {
if (idx < 0) return;
if (idx >= g_probe_cap) {
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
while (new_cap <= idx) new_cap *= 2;
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
if (arr == NULL) return;
for (int i = g_probe_cap; i < new_cap; i++) {
arr[i].probe = NULL;
arr[i].ts = 0;
}
g_probes = arr;
g_probe_cap = new_cap;
}
}
void process_info_record_probe(int tab_index, cJSON *probe) {
if (tab_index < 0 || probe == NULL) {
if (probe) cJSON_Delete(probe);
return;
}
probe_ensure(tab_index);
if (tab_index >= g_probe_cap) {
cJSON_Delete(probe);
return;
}
if (g_probes[tab_index].probe) {
cJSON_Delete(g_probes[tab_index].probe);
}
g_probes[tab_index].probe = probe; /* take ownership */
g_probes[tab_index].ts = time(NULL);
}
/* Return a *reference* (not a copy) to the stored probe for a tab, or
* NULL. Caller must NOT free. */
static cJSON *probe_get(int tab_index) {
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
return g_probes[tab_index].probe;
}
/* Deep-copy a probe (so the caller can free independently). */
static cJSON *probe_copy(int tab_index) {
cJSON *p = probe_get(tab_index);
if (p == NULL) return NULL;
char *s = cJSON_PrintUnformatted(p);
cJSON *copy = cJSON_Parse(s);
free(s);
return copy;
}
/* ── Process enumeration ────────────────────────────────────────────── */
/* Ownership tag for a discovered PID. */
typedef enum {
OWN_MAIN,
OWN_WEBKIT_RENDERER,
OWN_WEBKIT_NETWORK,
OWN_WEBKIT_GPU,
OWN_WEBKIT_STORAGE,
OWN_TOR,
OWN_FIPS,
OWN_OTHER
} proc_own_t;
/* Build a cJSON object for one process. `own` and `service_state`
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
* array (may be NULL) for renderers. */
static cJSON *build_proc_obj(guint pid, proc_own_t own,
const char *service_state,
cJSON *hosted_tabs) {
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) {
return NULL;
}
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
* resolution) instead of time(NULL) (1-second resolution) so the
* value updates on every poll even when the poll interval is ~1s. */
cpu_sample_t *s = cpu_sample_get(pid);
guint64 cur_jiffies = utime + stime;
gint64 now_us = g_get_monotonic_time();
double cpu_pct = 0.0;
if (s->prev_wall_us > 0) {
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
if (dt > 0) {
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
if (cpu_pct < 0) cpu_pct = 0;
}
}
s->prev_jiffies = cur_jiffies;
s->prev_wall_us = now_us;
long rss_kb = status_field_kb(pid, "VmRSS:");
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
long vmswap_kb = status_field_kb(pid, "VmSwap:");
long pss_kb = -1, uss_kb = -1;
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
long io_read = 0, io_write = 0;
parse_io(pid, &io_read, &io_write);
char task_path[64];
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
int threads = count_dir_entries(task_path);
if (threads < 0) threads = 0;
char fd_path[64];
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
int fds = count_dir_entries(fd_path);
if (fds < 0) fds = 0;
char cmdline[1024];
read_cmdline(pid, cmdline, sizeof(cmdline));
/* Uptime: starttime is in ticks since boot; btime is boot seconds
* since epoch. process_start = btime + starttime/clk. */
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
time_t uptime = time(NULL) - start_sec;
if (uptime < 0) uptime = 0;
const char *own_str = "other";
switch (own) {
case OWN_MAIN: own_str = "main"; break;
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
case OWN_TOR: own_str = "tor"; break;
case OWN_FIPS: own_str = "fips"; break;
default: own_str = "other"; break;
}
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "pid", (double)pid);
cJSON_AddStringToObject(o, "name", comm);
cJSON_AddStringToObject(o, "cmdline", cmdline);
cJSON_AddStringToObject(o, "state", state);
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
cJSON_AddNumberToObject(o, "threads", threads);
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
cJSON_AddNumberToObject(o, "fd_count", fds);
cJSON_AddStringToObject(o, "ownership", own_str);
if (service_state) {
cJSON_AddStringToObject(o, "service_state", service_state);
}
if (hosted_tabs) {
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
} else {
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
}
return o;
}
/* Map a net_service_type_t to a service_state string via net_services. */
static const char *service_state_for(net_service_type_t t) {
const net_service_t *s = net_service_get_status(t);
if (s == NULL) return NULL;
switch (s->state) {
case SERVICE_DISABLED: return "disabled";
case SERVICE_DISCOVERING: return "discovering";
case SERVICE_ATTACHING: return "attaching";
case SERVICE_STARTING: return "starting";
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
case SERVICE_READY: return "ready";
case SERVICE_STOPPING: return "stopping";
case SERVICE_EXITED: return "exited";
case SERVICE_FAILED: return "failed";
default: return "unknown";
}
}
/* Scan /proc for child processes of main_pid whose comm starts with
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
* (WebKitStorageProcess) and adds it to the `out` array with the
* appropriate ownership tag.
*
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* cannot map individual tabs to specific renderer PIDs here. Per-tab
* CPU attribution is handled by the Layer 2 probe instead. Renderer
* rows therefore show an empty hosted_tabs array. */
static void scan_webkit_procs(guint main_pid, cJSON *out) {
DIR *d = opendir("/proc");
if (d == NULL) return;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!isdigit((unsigned char)e->d_name[0])) continue;
guint pid = (guint)strtoul(e->d_name, NULL, 10);
if (pid == 0 || pid == main_pid) continue;
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) continue;
if (ppid != main_pid) continue;
if (strncmp(comm, "WebKit", 6) != 0) continue;
proc_own_t own = OWN_OTHER;
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
else continue; /* unknown WebKit aux — skip */
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
if (obj) cJSON_AddItemToArray(out, obj);
}
closedir(d);
}
cJSON *process_info_get_processes_json(void) {
cJSON *arr = cJSON_CreateArray();
guint main_pid = (guint)getpid();
/* Main process. */
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
/* WebKit child processes (renderers, network, gpu, storage).
* Discovered via /proc PPID scan — WebKitGTK 4.1 doesn't expose
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
scan_webkit_procs(main_pid, arr);
/* Tor + FIPS managed subprocesses. */
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
service_state_for(NET_SERVICE_TOR), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
service_state_for(NET_SERVICE_FIPS), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
return arr;
}
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
cJSON *process_info_get_tabs_json(void) {
cJSON *arr = cJSON_CreateArray();
int n = tab_manager_count();
for (int i = 0; i < n; i++) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL) continue;
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "index", i);
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(o, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(o, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* report 0 here. The Processes tab lists renderer PIDs from
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
cJSON *probe = probe_copy(i);
if (probe) {
cJSON_AddItemToObject(o, "probe", probe);
} else {
cJSON_AddNullToObject(o, "probe");
}
cJSON_AddItemToArray(arr, o);
}
return arr;
}
cJSON *process_info_get_tab_probe_json(int tab_index) {
tab_info_t *tab = tab_manager_get(tab_index);
if (tab == NULL) return NULL;
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "index", tab_index);
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(root, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(root, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
cJSON *probe = probe_copy(tab_index);
if (probe) {
cJSON_AddItemToObject(root, "probe", probe);
} else {
cJSON_AddNullToObject(root, "probe");
}
return root;
}

82
src/process_info.h Normal file
View File

@@ -0,0 +1,82 @@
/*
* process_info.h — process and per-tab performance diagnostics
*
* Provides the data backing for the sovereign://processes internal page.
* Two layers:
*
* Layer 1 — process view: enumerates the main browser PID, the WebKit
* child processes (renderer / network / gpu / storage) and
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
* service state. CPU% is computed from utime+stime deltas
* between successive calls (top-style).
*
* Layer 2 — per-tab probe: a JS user script (www/js/perf-probe.js)
* injected into every non-sovereign:// page reports a
* 1-second batch of long-task, timer, network, heap, and
* FPS samples via sovereign://processes/probe-report. The
* latest report per tab index is stored here and exposed to
* the UI and to MCP tools.
*
* All /proc parsing is Linux-specific and dependency-free.
*/
#ifndef PROCESS_INFO_H
#define PROCESS_INFO_H
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build the Layer 1 process list as a cJSON array (caller frees).
* Shape:
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
* "threads":12, "uptime_sec":..., "io_read_kb":...,
* "io_write_kb":..., "fd_count":..., "ownership":"main",
* "service_state":"ready" (Tor/FIPS only),
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
* (renderers only) }]
*
* The first call returns cpu_percent 0 for every process (no baseline);
* subsequent calls return accurate deltas. Caller should poll ~1s.
*/
cJSON *process_info_get_processes_json(void);
/*
* Build the Layer 2 tab list as a cJSON array (caller frees).
* Combines tab_manager state (index, title, url, WebProcess pid) with
* the most recent probe report for each tab (if any).
* Shape:
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
* "is_internal":true,
* "probe": { ...latest probe report, or null... } }]
*/
cJSON *process_info_get_tabs_json(void);
/*
* Build a drill-down JSON object for a single tab (caller frees).
* Returns the full latest probe report (including the long-task
* timeline and top sources) plus the tab's identity fields.
* Returns NULL if the tab index is out of range.
*/
cJSON *process_info_get_tab_probe_json(int tab_index);
/*
* Record a probe report received from a page's perf-probe.js.
* Takes ownership of `probe` (frees it). `tab_index` is the
* sovereign://processes/probe-report?tab_index=N query value.
* Stores a copy of the report fields; the timeline and top_sources
* arrays are preserved for the drill-down view.
*/
void process_info_record_probe(int tab_index, cJSON *probe);
#ifdef __cplusplus
}
#endif
#endif /* PROCESS_INFO_H */

View File

@@ -106,6 +106,46 @@ int profile_ensure_dir(const char *pubkey_hex) {
return mkdir_p(dir, 0700);
}
/* ── WebKit data directory ─────────────────────────────────────────── *
* Each user's WebKitWebsiteDataManager (cookies, cache, localStorage,
* IndexedDB, service workers, favicons) is rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ so web data is isolated
* per identity and persists across restarts. See plans/webkit-data-isolation.md.
*/
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';
return;
}
char dir[SB_HOME_BUF_SZ];
profile_get_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
if (out_sz > 0) out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%swebkit/", dir);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
int profile_ensure_webkit_dir(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
return -1;
}
char dir[SB_HOME_BUF_SZ];
profile_get_webkit_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
return -1;
}
return mkdir_p(dir, 0700);
}
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';

View File

@@ -47,6 +47,21 @@ void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
*/
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Get the per-user WebKit data directory for a given hex pubkey.
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/webkit/" to out.
* This is where the per-user WebKitWebsiteDataManager roots its cookies,
* cache, localStorage, IndexedDB, service workers, and favicons so web
* data is isolated per identity. See plans/webkit-data-isolation.md.
*/
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Create the per-user WebKit data directory if it doesn't already exist
* (mkdir -p style). Returns 0 on success, -1 on error.
*/
int profile_ensure_webkit_dir(const char *pubkey_hex);
/*
* Get the global identity.json path (stores the last-used pubkey_hex so
* the login dialog can default to the right profile).

View File

@@ -112,7 +112,7 @@ static void settings_set_defaults(browser_settings_t *s) {
s->agent_active_provider_name[0] = '\0';
memset(s->agent_providers, 0, sizeof(s->agent_providers));
s->tor_enabled = FALSE;
s->tor_enabled = TRUE;
snprintf(s->tor_mode, sizeof(s->tor_mode), "auto");
snprintf(s->tor_binary_path, sizeof(s->tor_binary_path), "tor");
s->tor_attach_socks[0] = '\0';
@@ -120,7 +120,7 @@ static void settings_set_defaults(browser_settings_t *s) {
snprintf(s->tor_data_dir, sizeof(s->tor_data_dir),
"~/.sovereign_browser/tor");
s->fips_enabled = FALSE;
s->fips_enabled = TRUE;
snprintf(s->fips_mode, sizeof(s->fips_mode), "auto");
snprintf(s->fips_binary_path, sizeof(s->fips_binary_path), "fips");
s->fips_control_socket[0] = '\0';

View File

@@ -78,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"open_settings", "Open settings",
"Open the sovereign://settings page", "<Control>comma"
},
[SHORTCUT_OPEN_PROCESSES] = {
"open_processes", "Open processes",
"Open the sovereign://processes diagnostics page",
"<Control><Shift>Escape"
},
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",

View File

@@ -41,6 +41,7 @@ typedef enum {
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_OPEN_PROCESSES,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,9 @@ typedef struct {
GtkWidget *tab_label; /* composite label widget for the tab strip */
GtkWidget *title_label; /* child of tab_label */
GtkWidget *favicon; /* favicon image in tab_label */
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
} tab_info_t;
@@ -44,6 +47,18 @@ void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window);
/*
* Update the WebKitWebContext that tab_manager_new_tab() and
* sidebar_create_webview() use to create new webviews. Used during an
* identity switch: the old context is torn down (after all tabs are
* closed) and a fresh per-user context is built; this function updates
* the stored pointer so subsequent new tabs use the new context.
*
* The caller MUST have closed all existing tabs and sidebar webviews
* (which reference the old context) before calling this.
*/
void tab_manager_set_context(WebKitWebContext *ctx);
/*
* Create a new tab and load the given URL (or the default new-tab URL
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
@@ -139,6 +154,25 @@ void tab_manager_close_to_right(int index);
*/
void tab_manager_close_all(void);
/*
* Destroy the agent chat sidebar webview in every open window (main + aux).
* The sidebar container itself is kept (it will be re-populated lazily on the
* next toggle). Used during logout / identity switch so the sidebar — which
* holds a long-lived WebKitWebView with the previous user's session — does
* not leak the old identity's sovereign://agents/chat state across users.
*/
void tab_manager_destroy_sidebar_webviews(void);
/*
* Temporarily suppress the "last tab closed → quit the app" behavior.
* Set TRUE before tab_manager_close_all() during an identity switch so
* the browser does not exit when the previous user's tabs are closed;
* the caller opens a fresh tab for the new user afterwards. Always
* reset to FALSE when done so normal quit-on-last-tab-close behavior
* is restored.
*/
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
/*
* Duplicate the tab at the given index (open a new tab with the same URL).
*/
@@ -206,6 +240,14 @@ WebKitWebView *tab_manager_get_main_webview(void);
*/
gboolean tab_manager_sidebar_visible(void);
/*
* Returns the main window's GtkNotebook widget. Used by main.c's
* delete-event handler to identify which tabs belong to the main window
* (vs auxiliary windows) when closing the main window but keeping aux
* windows alive.
*/
GtkWidget *tab_manager_get_main_notebook(void);
/*
* Re-hide the sidebar container after gtk_widget_show_all(window).
* Call this in main.c after show_all so the sidebar (which is packed

291
src/tor_scheme.c Normal file
View File

@@ -0,0 +1,291 @@
/*
* tor_scheme.c — asynchronous Tor-backed WebKitGTK handler for tor://
*/
#include "tor_scheme.h"
#include "net_services.h"
#include <curl/curl.h>
#include <glib.h>
#include <webkit2/webkit2.h>
#include <ctype.h>
#include <string.h>
#define TOR_FETCH_MAX_BYTES (16 * 1024 * 1024)
#define TOR_FETCH_TIMEOUT_SECONDS 30L
#define TOR_CONNECT_TIMEOUT_SECONDS 15L
typedef struct {
WebKitURISchemeRequest *request;
char *target_url;
char *proxy_url;
GString *response_body;
char *content_type;
char *error_html;
} tor_job_t;
typedef struct {
GString *body;
gboolean overflow;
} tor_body_t;
static char *html_escape(const char *input) {
if (!input) return g_strdup("");
return g_markup_escape_text(input, -1);
}
static void respond_html(WebKitURISchemeRequest *request, char *html) {
gsize len = strlen(html);
GInputStream *stream = g_memory_input_stream_new_from_data(html, len, g_free);
webkit_uri_scheme_request_finish(request, stream, len, "text/html");
g_object_unref(stream);
}
static void respond_error_page(WebKitURISchemeRequest *request,
const char *title,
const char *message) {
char *safe_title = html_escape(title ? title : "Error");
char *safe_message = html_escape(message ? message : "Request failed.");
char *html = g_strdup_printf(
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>%s</title></head><body>"
"<h1>%s</h1><p>%s</p></body></html>",
safe_title, safe_title, safe_message);
respond_html(request, html);
g_free(safe_title);
g_free(safe_message);
}
static gboolean host_is_onion(const char *host) {
return host && *host && g_str_has_suffix(host, ".onion");
}
static char *proxy_to_socks5h(const char *endpoint) {
if (!endpoint || endpoint[0] == '\0') return NULL;
if (g_str_has_prefix(endpoint, "socks5h://")) return g_strdup(endpoint);
if (!g_str_has_prefix(endpoint, "socks5://")) return NULL;
return g_strdup_printf("socks5h://%s", endpoint + strlen("socks5://"));
}
static char *build_target_http_url(const char *uri, char **error_message) {
GError *error = NULL;
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_PARSE_RELAXED, &error);
if (!parsed) {
if (error_message)
*error_message = g_strdup(error ? error->message : "Invalid tor:// URI");
g_clear_error(&error);
return NULL;
}
const char *host = g_uri_get_host(parsed);
gint port = g_uri_get_port(parsed);
const char *path = g_uri_get_path(parsed);
const char *query = g_uri_get_query(parsed);
if (!host_is_onion(host)) {
if (error_message) {
*error_message = g_strdup("tor:// only supports .onion hostnames in Phase 1");
}
g_uri_unref(parsed);
return NULL;
}
if (!path || path[0] == '\0') path = "/";
/* Phase 1: always map tor:// to http:// over Tor SOCKS. HTTPS-over-onion
* support can be added in a future phase with an explicit signal. */
char *target = NULL;
if (port > 0) {
target = query
? g_strdup_printf("http://%s:%d%s?%s", host, port, path, query)
: g_strdup_printf("http://%s:%d%s", host, port, path);
} else {
target = query
? g_strdup_printf("http://%s%s?%s", host, path, query)
: g_strdup_printf("http://%s%s", host, path);
}
g_uri_unref(parsed);
return target;
}
static size_t tor_write_bounded(void *data, size_t size, size_t nmemb, void *user_data) {
tor_body_t *body = user_data;
size_t bytes = size * nmemb;
if (body->body->len + bytes > TOR_FETCH_MAX_BYTES) {
body->overflow = TRUE;
return 0;
}
g_string_append_len(body->body, data, bytes);
return bytes;
}
static size_t tor_header_capture(void *data, size_t size, size_t nmemb, void *user_data) {
tor_job_t *job = user_data;
size_t bytes = size * nmemb;
const char *line = (const char *)data;
if (job->content_type != NULL || bytes < strlen("Content-Type:")) return bytes;
if (g_ascii_strncasecmp(line, "Content-Type:", strlen("Content-Type:")) == 0) {
const char *value = line + strlen("Content-Type:");
while (*value == ' ' || *value == '\t') value++;
const char *end = line + bytes;
while (end > value && (end[-1] == '\r' || end[-1] == '\n' ||
end[-1] == ' ' || end[-1] == '\t')) {
end--;
}
if (end > value) job->content_type = g_strndup(value, (gsize)(end - value));
}
return bytes;
}
static gpointer tor_scheme_worker(gpointer data) {
tor_job_t *job = data;
CURL *curl = curl_easy_init();
tor_body_t body = { g_string_new(NULL), FALSE };
if (!curl) {
job->error_html = g_strdup(
"Failed to load .onion site: Could not initialize HTTP client.");
g_string_free(body.body, TRUE);
return job;
}
curl_easy_setopt(curl, CURLOPT_URL, job->target_url);
curl_easy_setopt(curl, CURLOPT_PROXY, job->proxy_url);
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS5_HOSTNAME);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, TOR_FETCH_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, TOR_CONNECT_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/tor-scheme");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, tor_write_bounded);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, tor_header_capture);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, job);
CURLcode rc = curl_easy_perform(curl);
if (rc != CURLE_OK) {
const char *err = body.overflow
? "response exceeded maximum size"
: curl_easy_strerror(rc);
char *safe_err = html_escape(err);
job->error_html = g_strdup_printf("Failed to load .onion site: %s", safe_err);
g_free(safe_err);
g_string_free(body.body, TRUE);
curl_easy_cleanup(curl);
return job;
}
job->response_body = body.body;
if (!job->content_type || job->content_type[0] == '\0') {
g_free(job->content_type);
job->content_type = g_strdup("text/html");
}
curl_easy_cleanup(curl);
return job;
}
static void tor_job_free(tor_job_t *job) {
if (!job) return;
g_clear_object(&job->request);
g_free(job->target_url);
g_free(job->proxy_url);
if (job->response_body) g_string_free(job->response_body, TRUE);
g_free(job->content_type);
g_free(job->error_html);
g_free(job);
}
static void tor_scheme_worker_done(GObject *source_object,
GAsyncResult *result,
gpointer user_data) {
(void)source_object;
(void)user_data;
GTask *task = G_TASK(result);
tor_job_t *job = g_task_propagate_pointer(task, NULL);
if (job->error_html) {
respond_error_page(job->request, "Tor request failed", job->error_html);
tor_job_free(job);
return;
}
gsize len = job->response_body ? job->response_body->len : 0;
const char *data = (job->response_body && len > 0) ? job->response_body->str : "";
GInputStream *stream = g_memory_input_stream_new_from_data(g_memdup2(data, len),
len,
g_free);
webkit_uri_scheme_request_finish(job->request,
stream,
len,
job->content_type ? job->content_type : "text/html");
g_object_unref(stream);
tor_job_free(job);
}
static void tor_task_thread(GTask *task,
gpointer source_object,
gpointer task_data,
GCancellable *cancellable) {
(void)source_object;
(void)cancellable;
g_task_return_pointer(task, tor_scheme_worker(task_data), NULL);
}
static void on_tor_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
(void)user_data;
const char *uri = webkit_uri_scheme_request_get_uri(request);
char *url_error = NULL;
char *target_url = build_target_http_url(uri, &url_error);
if (!target_url) {
respond_error_page(request,
"Invalid .onion URL",
url_error ? url_error : "Invalid tor:// URL.");
g_free(url_error);
return;
}
if (!net_service_is_ready(NET_SERVICE_TOR)) {
respond_error_page(request,
"Tor unavailable",
"Tor is not ready. Please wait for Tor to bootstrap and try again.");
g_free(target_url);
return;
}
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
const char *endpoint = tor ? tor->status.tor.socks_endpoint : NULL;
char *proxy_url = proxy_to_socks5h(endpoint);
if (!proxy_url) {
respond_error_page(request,
"Tor unavailable",
"Tor SOCKS endpoint is not available yet. Please try again shortly.");
g_free(target_url);
return;
}
tor_job_t *job = g_new0(tor_job_t, 1);
job->request = g_object_ref(request);
job->target_url = target_url;
job->proxy_url = proxy_url;
GTask *task = g_task_new(NULL, NULL, tor_scheme_worker_done, NULL);
g_task_set_task_data(task, job, NULL);
g_task_run_in_thread(task, tor_task_thread);
g_object_unref(task);
}
void tor_scheme_register(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
webkit_web_context_register_uri_scheme(ctx, "tor", on_tor_scheme, NULL, NULL);
}

20
src/tor_scheme.h Normal file
View File

@@ -0,0 +1,20 @@
/*
* tor_scheme.h — WebKitGTK tor:// URI scheme handler
*/
#ifndef TOR_SCHEME_H
#define TOR_SCHEME_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
void tor_scheme_register(WebKitWebContext *ctx);
#ifdef __cplusplus
}
#endif
#endif /* TOR_SCHEME_H */

View File

@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.34"
#define SB_VERSION "v0.0.56"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 34
#define SB_VERSION_PATCH 56
#endif /* SOVEREIGN_BROWSER_VERSION_H */

156
src/web_context.c Normal file
View File

@@ -0,0 +1,156 @@
/*
* web_context.c — per-user WebKitWebContext construction/teardown
*
* See web_context.h for the rationale. Phase A of
* plans/webkit-data-isolation.md.
*/
#include "web_context.h"
#include "profile.h"
#include <glib.h>
#include <webkit2/webkit2.h>
#include <string.h>
/* The current per-user context. NULL before the first build and after
* teardown. Replaces webkit_web_context_get_default() for code that
* wants the per-user context. */
static WebKitWebContext *g_ctx = NULL;
/* ── Security / TLS / favicon configuration ────────────────────────── *
* Applied to every context we build (per-user and ephemeral). Mirrors
* the configuration that main.c used to apply to the default context.
*/
static void configure_context(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
/* Security strip: register sovereign://, tor://, file:// as secure
* (no CORS enforcement), and file:// as local. Same as the original
* main.c configuration. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(ctx);
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *dm =
webkit_web_context_get_website_data_manager(ctx);
webkit_website_data_manager_set_tls_errors_policy(
dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Enable the favicon database so notify::favicon fires. Use the
* data manager's base data directory (per-user) so favicons are
* isolated too. Passing NULL would use a shared default location. */
char fav_dir[600];
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(ctx);
const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr);
if (base != NULL) {
g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base);
} else {
/* Ephemeral data manager has no base dir; use a temp favicons
* dir so the favicon DB still works in no-login mode. */
g_snprintf(fav_dir, sizeof(fav_dir),
"%s/sovereign_browser-favicons-ephemeral",
g_get_tmp_dir());
}
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
}
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[web-context] No pubkey — cannot build per-user context\n");
return NULL;
}
/* Ensure the per-user webkit data directory exists. */
if (profile_ensure_webkit_dir(pubkey_hex) != 0) {
g_printerr("[web-context] Failed to create webkit dir for %s\n",
pubkey_hex);
return NULL;
}
char webkit_dir[600];
profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir));
if (webkit_dir[0] == '\0') {
g_printerr("[web-context] Failed to get webkit dir path for %s\n",
pubkey_hex);
return NULL;
}
/* Build the per-user data manager. base-data-directory covers
* local-storage, indexed-db, websql, service-worker-registrations,
* offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory
* covers disk-cache. WebKit derives subdirs under these. */
char data_dir[600];
char cache_dir[600];
g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir);
g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir);
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new(
"base-data-directory", data_dir,
"base-cache-directory", cache_dir,
NULL);
if (dm == NULL) {
g_printerr("[web-context] Failed to create WebsiteDataManager\n");
return NULL;
}
/* Build a fresh context bound to this data manager. */
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
if (g_ctx == NULL) {
g_printerr("[web-context] Failed to create WebContext\n");
g_object_unref(dm);
return NULL;
}
/* The context owns the data manager now; drop our ref. */
g_object_unref(dm);
configure_context(g_ctx);
g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n",
pubkey_hex, data_dir, cache_dir);
return g_ctx;
}
WebKitWebContext *web_context_build_ephemeral(void) {
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral();
if (dm == NULL) {
g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n");
return NULL;
}
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
if (g_ctx == NULL) {
g_printerr("[web-context] Failed to create ephemeral WebContext\n");
g_object_unref(dm);
return NULL;
}
g_object_unref(dm);
configure_context(g_ctx);
g_print("[web-context] Built ephemeral context (no on-disk persistence)\n");
return g_ctx;
}
void web_context_teardown(void) {
if (g_ctx == NULL) return;
/* Unref the context. The context releases its reference to the data
* manager. Any remaining webviews referencing this context would
* keep it alive — the caller must have closed them first. */
WebKitWebContext *old = g_ctx;
g_ctx = NULL;
g_object_unref(old);
g_print("[web-context] Torn down previous context\n");
}
WebKitWebContext *web_context_get(void) {
return g_ctx;
}

88
src/web_context.h Normal file
View File

@@ -0,0 +1,88 @@
/*
* web_context.h — per-user WebKitWebContext construction/teardown
*
* Phase A of plans/webkit-data-isolation.md. Each Nostr identity gets
* its own WebKitWebContext backed by a per-user WebKitWebsiteDataManager
* rooted at ~/.sovereign_browser/profiles/<pubkey>/webkit/. This gives
* true isolation: cookies, cache, localStorage, IndexedDB, service
* workers, and favicons persist per-user and never cross-contaminate.
*
* WebKitGTK binds a WebKitWebsiteDataManager to a WebKitWebContext for
* the context's lifetime, so per-user isolation requires building a
* fresh context per login and tearing it down on logout/switch.
*
* The current context pointer is held in this module so the rest of the
* codebase can fetch it via web_context_get() instead of
* webkit_web_context_get_default().
*/
#ifndef WEB_CONTEXT_H
#define WEB_CONTEXT_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build a fresh WebKitWebContext with a per-user WebKitWebsiteDataManager
* rooted at ~/.sovereign_browser/profiles/<pubkey_hex>/webkit/.
*
* pubkey_hex — 64-char hex pubkey. Must be non-NULL/non-empty.
*
* Configures:
* - per-user base-data-directory + base-cache-directory (WebKit derives
* local-storage, indexed-db, websql, service-worker, offline-app-cache,
* hsts, itp, device-id-hash-salt under these)
* - favicon database enabled (per-user)
* - TLS errors ignored (FIPS uses Noise IK, not TLS CAs)
* - security manager: sovereign://, tor://, file:// registered as
* secure; file:// also registered as local
*
* Stores the new context as the current context (returned by
* web_context_get()). The caller must call web_context_teardown() on the
* previous context first if one exists.
*
* Returns the new WebKitWebContext (owned by this module), or NULL on
* failure.
*/
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex);
/*
* Build a fresh WebKitWebContext with an EPHEMERAL data manager (no
* on-disk persistence). Used for --no-login / read-only mode where
* there is no pubkey to key a per-user data directory on, and for the
* logged-out state. Same security/TLS/favicon config as
* web_context_build_for_pubkey.
*
* Stores the new context as the current context.
*
* Returns the new WebKitWebContext, or NULL on failure.
*/
WebKitWebContext *web_context_build_ephemeral(void);
/*
* Tear down the current per-user WebKitWebContext: unref the context
* and its data manager. After this, web_context_get() returns NULL
* until the next build call.
*
* The caller MUST have already closed all webviews that reference the
* old context (tabs + sidebar webviews) before calling this, otherwise
* the webviews will hold dangling references. identity_teardown_web_state()
* in main.c does this.
*/
void web_context_teardown(void);
/*
* Return the current WebKitWebContext, or NULL if none has been built
* (or the last one was torn down). Replaces webkit_web_context_get_default()
* for code that wants the per-user context.
*/
WebKitWebContext *web_context_get(void);
#ifdef __cplusplus
}
#endif
#endif /* WEB_CONTEXT_H */

113
src/webkit_data.c Normal file
View File

@@ -0,0 +1,113 @@
/*
* webkit_data.c — helpers to wipe WebKit website data on identity change
*
* See webkit_data.h for the rationale. This is Phase 0 of
* plans/webkit-data-isolation.md: wipe the shared data manager in place on
* logout / identity switch so the next user does not inherit the previous
* user's cookies, cache, localStorage, service workers, etc.
*/
#include "webkit_data.h"
#include <glib.h>
#include <webkit2/webkit2.h>
/* ── Async clear state ─────────────────────────────────────────────── *
* webkit_website_data_manager_clear() is asynchronous. We drive a nested
* GLib main loop until the clear callback fires (or a 5s timeout expires)
* so callers can treat webkit_data_clear_all() as synchronous.
*/
typedef struct {
gboolean done;
gboolean timed_out;
} clear_state_t;
static void on_clear_finished(GObject *source, GAsyncResult *res,
gpointer user_data) {
(void)source;
(void)res;
clear_state_t *st = (clear_state_t *)user_data;
st->done = TRUE;
}
static gboolean on_clear_timeout(gpointer user_data) {
clear_state_t *st = (clear_state_t *)user_data;
if (!st->done) {
st->timed_out = TRUE;
st->done = TRUE;
}
return G_SOURCE_REMOVE;
}
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons) {
if (ctx == NULL) {
ctx = webkit_web_context_get_default();
}
if (ctx == NULL) {
g_printerr("[webkit-data] No WebKitWebContext to clear\n");
return -1;
}
WebKitWebsiteDataManager *dm =
webkit_web_context_get_website_data_manager(ctx);
if (dm == NULL) {
g_printerr("[webkit-data] No WebKitWebsiteDataManager on context\n");
return -1;
}
/* Build the set of data types to clear. WEBKIT_WEBSITE_DATA_ALL covers:
* memory cache, disk cache, offline app cache, cookies, local storage,
* session storage, IndexedDB, WebSQL, service worker registrations,
* HSTS, plugin data, device id/hash salt. */
WebKitWebsiteDataTypes types = WEBKIT_WEBSITE_DATA_ALL;
/* Drive the async clear to completion. The clear callback and the
* timeout fire on the default main context (where WebKit schedules
* its async completions), so we pump the default context — NOT a
* fresh nested context, which would never see the callback.
*
* We do NOT push a thread-default context because the caller (the
* MCP request handler) is already running inside the default main
* loop; iterating the default context here lets the clear callback
* fire while still on the same thread. */
clear_state_t st = { .done = FALSE, .timed_out = FALSE };
guint timeout_id = g_timeout_add_seconds(5, on_clear_timeout, &st);
webkit_website_data_manager_clear(dm, types, 0, NULL,
on_clear_finished, &st);
/* Pump the default context until the callback or timeout sets
* st.done. g_main_context_iteration may return FALSE if no sources
* are ready, so we loop on the flag, not the return value. */
while (!st.done) {
g_main_context_iteration(NULL, TRUE);
}
if (timeout_id != 0) {
g_source_remove(timeout_id);
timeout_id = 0;
}
if (st.timed_out) {
g_printerr("[webkit-data] clear timed out after 5s\n");
return -2;
}
/* Favicons live in a separate WebKitFaviconDatabase, not the data
* manager. Clearing it is optional because it is process-global and
* not identity-sensitive (favicons are page icons, not user data).
* When requested, clear it so a switched user doesn't see the prior
* user's visited-site icons in the tab strip. */
if (clear_favicons) {
WebKitFaviconDatabase *favdb =
webkit_web_context_get_favicon_database(ctx);
if (favdb != NULL) {
webkit_favicon_database_clear(favdb);
}
}
g_print("[webkit-data] Cleared all website data (favicons=%s)\n",
clear_favicons ? "yes" : "no");
return 0;
}

54
src/webkit_data.h Normal file
View File

@@ -0,0 +1,54 @@
/*
* webkit_data.h — helpers to wipe WebKit website data on identity change
*
* WebKitGTK's WebKitWebsiteDataManager holds cookies, HTTP cache, localStorage,
* IndexedDB, WebSQL, service worker registrations, offline app cache, HSTS,
* and the favicon database for the whole process. When a user logs out or
* switches Nostr identity, this data must be cleared so the next user does
* not see the previous user's web session (cookies are the primary leak —
* they identify you to web pages).
*
* Phase 0 of plans/webkit-data-isolation.md: a single shared data manager
* (the default context's) is wiped in place. Phase A will replace this with
* per-user data managers, at which point these helpers become the safety net
* for the in-memory live-tab case.
*/
#ifndef WEBKIT_DATA_H
#define WEBKIT_DATA_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Clear ALL website data from the given WebKitWebContext's data manager:
* cookies, disk + memory cache, local storage, session storage, IndexedDB,
* WebSQL, service worker registrations, offline app cache, HSTS, plugin data,
* and (optionally) favicons.
*
* ctx — the WebKitWebContext whose data manager to clear.
* Pass NULL to target the default context.
* clear_favicons — if TRUE, also clear the favicon database. The favicon
* DB is process-global and shared across contexts; clearing
* it drops every cached favicon, not just the current
* site's. Pass FALSE if you only want session/cache
* isolation (favicons are not identity-sensitive).
*
* This is asynchronous in WebKitGTK (the clear API takes a callback). This
* function runs a nested GLib main loop until the clear completes (or 5s
* elapses) so that on return the data is gone. It is safe to call from the
* GTK main thread.
*
* Returns 0 on success, -1 if the context/data manager could not be resolved,
* -2 if the clear timed out.
*/
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons);
#ifdef __cplusplus
}
#endif
#endif /* WEBKIT_DATA_H */

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head><title>CPU Hog Test</title></head>
<body>
<h1>CPU Hog Test</h1>
<p>This page runs a busy loop in setInterval to generate long tasks.</p>
<pre id="out"></pre>
<script>
// Busy loop every 250ms — should show up as long tasks + high cpu_busy_percent.
setInterval(function busyLoop() {
var start = performance.now();
while (performance.now() - start < 120) { /* burn 120ms */ }
document.getElementById('out').textContent =
'tick at ' + new Date().toISOString();
}, 250);
// Also spin a few extra timers to populate the timer_top list.
setInterval(function pollStatus() {
fetch('/test.json').catch(function(){});
}, 1000);
setInterval(function pollStatus2() {
fetch('/test.json').catch(function(){});
}, 2000);
</script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Identity Leak Test</title>
<style>
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 2rem; max-width: 720px; }
h1 { font-size: 1.4rem; }
.box { border: 1px solid #ccc; padding: 1rem 1.25rem; margin: 1rem 0; border-radius: 6px; }
.marker { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.1rem; word-break: break-all; }
.fresh { color: #080; }
.leaked { color: #c00; font-weight: 600; }
button { font: inherit; padding: .35rem .8rem; margin: .25rem .25rem .25rem 0; cursor: pointer; }
code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
ul { margin: .5rem 0; }
</style>
</head>
<body>
<h1>Identity Leak Test</h1>
<p>This page writes a per-session marker to <code>localStorage</code> and a
session cookie, then shows them on every load. Use it to verify that logging
out and back in as a different identity does <strong>not</strong> leak the
previous user's marker.</p>
<div class="box">
<strong>Marker for this session:</strong>
<div id="marker" class="marker">(none yet)</div>
<div id="status"></div>
</div>
<div class="box">
<strong>Stored values seen by this page:</strong>
<ul>
<li><code>localStorage["sb_identity_marker"]</code> = <span id="ls-val">(none)</span></li>
<li><code>document.cookie</code> = <span id="ck-val">(none)</span></li>
</ul>
</div>
<p>
<button id="set">Write fresh marker</button>
<button id="clear">Clear marker</button>
<button id="reload">Reload page</button>
</p>
<script>
(function () {
var KEY = "sb_identity_marker";
function freshMarker() {
// Random hex string + timestamp so each "write" is distinguishable.
var rnd = Math.random().toString(16).slice(2, 10);
var ts = Date.now().toString(16);
return "user-" + ts + "-" + rnd;
}
function render() {
var ls = localStorage.getItem(KEY) || "";
var ck = "";
document.cookie.split("; ").forEach(function (c) {
if (c.indexOf(KEY + "=") === 0) ck = c.slice(KEY.length + 1);
});
var el = document.getElementById("marker");
var st = document.getElementById("status");
var lsEl = document.getElementById("ls-val");
var ckEl = document.getElementById("ck-val");
lsEl.textContent = ls || "(none)";
ckEl.textContent = ck || "(none)";
if (ls && ck && ls === ck) {
el.textContent = ls;
el.className = "marker fresh";
st.textContent = "Marker present from a previous visit on this identity.";
} else if (ls || ck) {
el.textContent = (ls || ck);
el.className = "marker leaked";
st.textContent = "PARTIAL: one of localStorage/cookie is set but not both. " +
"If this appears right after switching identities, it is a leak.";
} else {
el.textContent = "(none — fresh identity)";
el.className = "marker";
st.textContent = "No marker found. This is what a fresh identity should see.";
}
}
document.getElementById("set").addEventListener("click", function () {
var m = freshMarker();
localStorage.setItem(KEY, m);
// Session cookie (no max-age) scoped to this origin.
document.cookie = KEY + "=" + m + "; path=/";
render();
});
document.getElementById("clear").addEventListener("click", function () {
localStorage.removeItem(KEY);
document.cookie = KEY + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
render();
});
document.getElementById("reload").addEventListener("click", function () {
location.reload();
});
render();
})();
</script>
</body>
</html>

142
tests/test_bookmarks_tree.c Normal file
View File

@@ -0,0 +1,142 @@
/*
* test_bookmarks_tree.c — unit tests for the bookmark tree/HMAC d-tag logic
*
* Tests the pure pieces that don't require a signer, database, or relay
* connection:
* - HMAC-SHA256 d-tag derivation is deterministic (same key + path → same hex)
* - HMAC-SHA256 d-tag derivation is opaque (different paths → uncorrelated)
* - is_hmac_d_tag correctly distinguishes 64-hex HMAC tags from legacy
* plaintext directory names
*
* The full trie operations (add/remove/move/rename/delete) require a signer
* and database, so they are exercised via the browser's MCP tools at runtime
* rather than in this unit test.
*
* Build (standalone, links against nostr_core_lib):
* cc -std=c99 -Wall -Wextra -g -I./nostr_core_lib \
* tests/test_bookmarks_tree.c \
* nostr_core_lib/libnostr_core_x64.a \
* -lsecp256k1 -lssl -lcrypto -lm -o tests/test_bookmarks_tree
* ./tests/test_bookmarks_tree
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../nostr_core_lib/nostr_core/nostr_core.h"
static int failures = 0;
static int passes = 0;
#define CHECK(cond, msg) do { \
if (cond) { passes++; } \
else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \
} while (0)
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.h. */
#define LABEL "sovereign-browser/bookmarks-folder-id-v1"
/* Mirror of path_to_d_tag in src/bookmarks.c (single-step scheme):
* d = HMAC-SHA256(privkey, LABEL + ":" + path) */
static char *path_to_d_tag(const unsigned char *privkey, const char *path) {
if (path == NULL) path = "";
char data[512];
snprintf(data, sizeof(data), "%s:%s", LABEL, path);
unsigned char mac[32];
if (nostr_hmac_sha256(privkey, 32,
(const unsigned char *)data, strlen(data),
mac) != 0) {
return NULL;
}
char *hex = malloc(65);
if (hex == NULL) return NULL;
nostr_bytes_to_hex(mac, 32, hex);
hex[64] = '\0';
return hex;
}
/* Mirror of is_hmac_d_tag in src/bookmarks.c. */
static int is_hmac_d_tag(const char *s) {
if (s == NULL || strlen(s) != 64) return 0;
for (const char *p = s; *p; p++) {
if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f')))
return 0;
}
return 1;
}
int main(void) {
/* Use a fixed test privkey (32 bytes of 0x01..0x20). */
unsigned char privkey[32];
for (int i = 0; i < 32; i++) privkey[i] = (unsigned char)(i + 1);
/* ── Determinism: same path → same d tag ────────────────────────── */
char *d1 = path_to_d_tag(privkey, "Work/Projects/Secret");
char *d2 = path_to_d_tag(privkey, "Work/Projects/Secret");
CHECK(d1 != NULL, "path_to_d_tag returned NULL");
CHECK(d2 != NULL, "path_to_d_tag returned NULL (2nd call)");
CHECK(d1 && d2 && strcmp(d1, d2) == 0,
"Same path should produce the same d tag (determinism)");
free(d1);
free(d2);
/* ── Opaqueness: different paths → different d tags ─────────────── */
char *da = path_to_d_tag(privkey, "Work");
char *db = path_to_d_tag(privkey, "Work/Projects");
char *dc = path_to_d_tag(privkey, "Personal");
CHECK(da && db && strcmp(da, db) != 0,
"Different paths should produce different d tags (no prefix leakage)");
CHECK(da && dc && strcmp(da, dc) != 0,
"Different paths should produce different d tags (2)");
/* The d tag should NOT contain the plaintext path. */
CHECK(da && strstr(da, "Work") == NULL,
"d tag should not leak the plaintext path");
CHECK(db && strstr(db, "Work") == NULL,
"d tag should not leak the plaintext path (child)");
free(da);
free(db);
free(dc);
/* ── d tag is 64 lowercase hex chars ────────────────────────────── */
char *d = path_to_d_tag(privkey, "General");
CHECK(d != NULL, "path_to_d_tag returned NULL for General");
CHECK(d && strlen(d) == 64, "d tag should be 64 hex chars");
CHECK(d && is_hmac_d_tag(d), "d tag should pass is_hmac_d_tag");
free(d);
/* ── is_hmac_d_tag distinguishes legacy names ───────────────────── */
CHECK(is_hmac_d_tag("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"64 lowercase hex should be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag("General"),
"Legacy plaintext 'General' should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag("Work/Projects"),
"Legacy plaintext path with slash should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag(""),
"Empty string should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag(NULL),
"NULL should NOT be recognized as an HMAC d tag");
/* Uppercase hex is not produced by nostr_bytes_to_hex (lowercase), so
* treat it as non-HMAC (legacy). */
CHECK(!is_hmac_d_tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
"Uppercase hex should NOT be recognized as an HMAC d tag (we emit lowercase)");
/* ── Different privkeys → different d tags for the same path ────── */
unsigned char privkey2[32];
for (int i = 0; i < 32; i++) privkey2[i] = (unsigned char)(255 - i);
char *e1 = path_to_d_tag(privkey, "General");
char *e2 = path_to_d_tag(privkey2, "General");
CHECK(e1 && e2 && strcmp(e1, e2) != 0,
"Different privkeys should produce different d tags for the same path");
free(e1);
free(e2);
/* ── Empty path is allowed (root) ───────────────────────────────── */
char *root_d = path_to_d_tag(privkey, "");
CHECK(root_d != NULL, "path_to_d_tag should handle empty path");
CHECK(root_d && strlen(root_d) == 64,
"Empty path d tag should still be 64 hex chars");
free(root_d);
printf("bookmarks tree tests: %d passed, %d failed\n", passes, failures);
return failures == 0 ? 0 : 1;
}

View File

@@ -0,0 +1,134 @@
#!/bin/bash
# tests/test_identity_isolation.sh
#
# End-to-end test for the WebKit data isolation fix (Phase 0 of
# plans/webkit-data-isolation.md). Verifies that switching Nostr identity
# wipes the previous user's cookies + localStorage so the next user does
# not see the previous user's web session.
#
# Prereqs:
# - ./browser.sh start --login-method generate (user A is logged in)
# - python3 -m http.server 8765 running in tests/local-site/
#
# Flow:
# 1. Capture user A's pubkey via login_status.
# 2. Open http://localhost:8765/identity-leak-test.html
# 3. Click "Write fresh marker" — writes localStorage + cookie.
# 4. Read the marker value via eval.
# 5. switch_identity to a fresh generated key (user B).
# 6. Reopen the test page.
# 7. Read localStorage + cookie via eval.
# 8. PASS if both are empty; FAIL if user A's marker leaked through.
set -u
MCP="http://localhost:17777/mcp"
URL="http://localhost:8765/identity-leak-test.html"
# MCP responses are SSE-formatted: "event: message\ndata: {json}\n\n".
# mcp_call <json> -> extracts the inner tool result text (a JSON string)
mcp_call() {
local payload="$1"
local raw
raw=$(curl -s -X POST "$MCP" -H 'Content-Type: application/json' -d "$payload")
# Extract the data: line, then parse the nested JSON with python.
echo "$raw" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"^data: (.+)$", raw, re.MULTILINE)
if not m:
print("ERROR: no data line", file=sys.stderr)
sys.exit(2)
outer = json.loads(m.group(1))
content = outer.get("result", {}).get("content", [])
if not content:
print("ERROR: no content", file=sys.stderr)
sys.exit(2)
text = content[0].get("text", "")
# text is itself a JSON string: {"success":true,"data":{...},"id":N}
try:
inner = json.loads(text)
print(json.dumps(inner))
except json.JSONDecodeError:
print(text)
'
}
echo "[test] Checking browser is up + logged in..."
STATUS=$(mcp_call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login_status","arguments":{}}}')
PUBKEY_A=$(echo "$STATUS" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey",""))')
if [ -z "${PUBKEY_A:-}" ]; then
echo "[test] FAIL: not logged in as user A"
echo "$STATUS"
exit 1
fi
echo "[test] User A pubkey: $PUBKEY_A"
echo "[test] Opening test page..."
mcp_call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null
sleep 1.5
# Write the marker directly via eval (same effect as clicking the
# "Write fresh marker" button, but deterministic — no dependency on the
# click tool's selector/ref resolution).
echo "[test] Writing marker as user A via eval..."
MARKER_A="userA-$(date +%s)-$RANDOM"
mcp_call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.setItem(\"sb_identity_marker\",\"'"$MARKER_A"'\"); document.cookie=\"sb_identity_marker='"$MARKER_A"'; path=/\"; \"wrote\""}}}' >/dev/null
sleep 0.5
echo "[test] Reading marker back as user A..."
READBACK=$(mcp_call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
echo "[test] User A marker readback: '${READBACK:-<empty>}'"
MARKER_A="$READBACK"
if [ -z "${MARKER_A:-}" ]; then
echo "[test] FAIL: could not read user A marker — test setup issue"
exit 1
fi
echo "[test] Switching identity to a fresh generated key (user B)..."
SWITCH=$(mcp_call '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"switch_identity","arguments":{"method":"generate"}}}')
echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("switch result:", "OK" if d.get("success") else "FAIL", d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")'
PUBKEY_B=$(echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")')
if [ -z "${PUBKEY_B:-}" ]; then
echo "[test] FAIL: switch_identity did not return a pubkey"
echo "$SWITCH"
exit 1
fi
if [ "$PUBKEY_A" = "$PUBKEY_B" ]; then
echo "[test] FAIL: switch_identity returned the same pubkey"
exit 1
fi
echo "[test] User B pubkey: $PUBKEY_B"
sleep 1.5
echo "[test] Reopening test page as user B..."
mcp_call '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null
sleep 1.5
echo "[test] Reading localStorage + cookie as user B..."
LS_B=$(mcp_call '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
CK_B=$(mcp_call '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"eval","arguments":{"script":"(document.cookie.split(\"; \").find(function(c){return c.indexOf(\"sb_identity_marker=\")===0})||\"\").split(\"=\").slice(1).join(\"=\")"}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
echo "[test] User B localStorage: '${LS_B:-<empty>}'"
echo "[test] User B cookie: '${CK_B:-<empty>}'"
PASS=1
if [ -n "${LS_B:-}" ]; then
echo "[test] FAIL: localStorage leaked from user A to user B"
PASS=0
fi
if [ -n "${CK_B:-}" ]; then
echo "[test] FAIL: cookie leaked from user A to user B"
PASS=0
fi
if [ "$PASS" = "1" ]; then
echo "[test] PASS: identity switch wiped user A's web data; user B sees a fresh session."
exit 0
else
echo "[test] FAIL: identity leak detected."
exit 1
fi

View File

@@ -98,7 +98,7 @@
<button class="save-btn" onclick="saveAsSkill()">Save as Skill</button></div>
</div>
<p><a href="sovereign://agents/chat">Open Agent Chat</a></p>
<p><a href="sovereign://agents/chat" target="_blank">Open Agent Chat</a></p>
<div id="status" class="status"></div>

View File

@@ -4,5 +4,90 @@
* sovereign://sovereign-base.css. */
@import url("sovereign://sovereign-base.css");
/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */
#bm-dir { min-width: 160px; }
/* ── Tree view ─────────────────────────────────────────────────────── */
.tree-node {
margin: 2px 0;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.caret {
display: inline-block;
width: 14px;
cursor: pointer;
user-select: none;
color: var(--primary);
font-size: 10px;
transition: transform 0.15s ease;
transform: rotate(0deg);
flex-shrink: 0;
}
.caret.expanded {
transform: rotate(90deg);
}
.folder-icon {
font-size: 16px;
flex-shrink: 0;
}
.folder-name {
font-weight: bold;
color: var(--primary);
flex-shrink: 0;
}
.folder-actions {
display: flex;
gap: 6px;
margin-left: auto;
flex-wrap: wrap;
}
/* Smaller buttons in folder actions so the row stays compact. */
.folder-actions .btn {
padding: 3px 10px;
font-size: 11px;
margin-left: 0;
}
/* Ensure every .btn renders the themed 1px solid border. WebKitGTK's UA
* stylesheet gives <button> a native 2px outset grey border tied to
* -webkit-appearance: button. Setting appearance: none strips the native
* chrome so the author border applies. The !important guards against the
* UA !important on border-width/style/color. */
.btn, .save-btn, button.btn {
-webkit-appearance: none !important;
appearance: none !important;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--primary) !important;
border-radius: var(--radius);
display: inline-block;
text-decoration: none;
}
.btn:hover, .save-btn:hover, button.btn:hover {
border-color: var(--accent) !important;
text-decoration: none;
}
.btn-del { border-color: var(--accent) !important; color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
.tree-children {
border-left: 1px dashed var(--border);
padding-left: 4px;
}
/* Reuse .bm / .bm-title / .bm-url / .bm-meta / .actions / .btn / .btn-del
* from the base theme for bookmark rows. */
#bm-path, #new-path { min-width: 200px; }

View File

@@ -19,17 +19,17 @@
<div class="form">
<input type="text" id="bm-url" placeholder="URL" size="40">
<input type="text" id="bm-title" placeholder="Title" size="25">
<select id="bm-dir"></select>
<input type="text" id="bm-path" placeholder="Folder path (e.g. Work/Projects)" size="25">
<button class="btn" onclick="addBookmark()">Add</button>
</div>
<div class="form">
<input type="text" id="new-dir" placeholder="New directory name" size="25">
<button class="btn" onclick="createDir()">Create Directory</button>
<input type="text" id="new-path" placeholder="New folder path (e.g. Work/Projects/Secret)" size="35">
<button class="btn" onclick="createDir()">Create Folder</button>
</div>
</div>
<h2>Bookmarks</h2>
<div id="bm-list"></div>
<div id="bm-tree"></div>
<script src="sovereign://bookmarks.js"></script>
</body>

View File

@@ -1,8 +1,13 @@
/* Bookmarks page — sovereign://bookmarks
*
* Fetches the bookmark directory list from sovereign://bookmarks/list
* (JSON) and renders it. Add/delete/create-dir use the existing
* sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints.
* Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and
* renders it as an expandable/collapsible tree. Folders may be nested
* arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename
* use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir}
* endpoints.
*
* Expand/collapse state is persisted in localStorage so the user's
* expanded folders survive page reloads.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
@@ -24,75 +29,255 @@ function esc(s) {
return d.innerHTML;
}
function renderBookmarks(data) {
/* ── Expand/collapse state (persisted in localStorage) ─────────────── */
var EXPANDED_KEY = 'sovereign_bookmarks_expanded';
function getExpanded() {
try {
var raw = localStorage.getItem(EXPANDED_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) { return {}; }
}
function setExpanded(path, isExpanded) {
var state = getExpanded();
if (isExpanded) state[path] = true;
else delete state[path];
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); }
catch (e) { /* ignore quota errors */ }
}
/* ── Tree rendering ────────────────────────────────────────────────── */
function renderTree(data) {
var haveSigner = !!data.have_signer;
document.getElementById('readonly-note').style.display =
haveSigner ? 'none' : '';
document.getElementById('add-form').style.display =
haveSigner ? '' : 'none';
/* Populate the directory <select> for the add form. */
var sel = document.getElementById('bm-dir');
sel.innerHTML = '';
(data.dirs || []).forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name;
sel.appendChild(opt);
});
var c = document.getElementById('bm-list');
var dirs = data.dirs || [];
if (dirs.length === 0) {
var c = document.getElementById('bm-tree');
var tree = data.tree;
if (!tree || !tree.children || tree.children.length === 0) {
c.innerHTML =
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
'in the toolbar to save a page.</p>';
return;
}
var html = '';
dirs.forEach(function(dir) {
html += '<div class="dir-header"><h3>' + esc(dir.name) +
' (' + dir.count + ')</h3>';
if (haveSigner && dir.name !== 'General') {
var enc = encodeURIComponent(dir.name);
html += ' <a class="btn btn-del" href="sovereign://bookmarks/deletedir?dir=' +
enc + '" onclick="return confirm(\'Delete directory "' +
esc(dir.name) + '"? Bookmarks will be moved to General.\')">Delete Dir</a>';
}
html += '</div>';
if (dir.count === 0) {
html += '<p class="empty">(empty)</p>';
} else {
(dir.bookmarks || []).forEach(function(bm) {
var urlEnc = encodeURIComponent(bm.url);
var dirEnc = encodeURIComponent(dir.name);
var title = bm.title && bm.title.length ? bm.title : '(untitled)';
html += '<div class="bm"><div>';
html += '<div class="bm-title">' + esc(title) + '</div>';
html += '<div><a class="bm-url" href="' + esc(bm.url) + '">' +
esc(bm.url) + '</a></div>';
html += '<div class="bm-meta">Added: ' + esc(bm.added) + '</div></div>';
html += '<div class="actions">';
html += '<a class="btn" href="' + esc(bm.url) + '">Open</a>';
if (haveSigner) {
html += ' <a class="btn btn-del" href="sovereign://bookmarks/delete?dir=' +
dirEnc + '&url=' + urlEnc +
'" onclick="return confirm(\'Delete this bookmark?\')">Delete</a>';
}
html += '</div></div>';
});
}
c.innerHTML = '';
(tree.children || []).forEach(function(child) {
c.appendChild(renderNode(child, haveSigner, 0));
});
c.innerHTML = html;
}
function renderNode(node, haveSigner, depth) {
/* A folder row + its bookmark list + its children (recursive). */
var expandedState = getExpanded();
var isExpanded = !!expandedState[node.path];
var wrap = document.createElement('div');
wrap.className = 'tree-node';
/* Folder row. */
var row = document.createElement('div');
row.className = 'folder-row';
var caret = document.createElement('span');
caret.className = 'caret' + (isExpanded ? ' expanded' : '');
caret.textContent = '\u25B6'; /* right-pointing triangle */
caret.onclick = function() {
var nowExpanded = caret.classList.toggle('expanded');
childList.style.display = nowExpanded ? '' : 'none';
setExpanded(node.path, nowExpanded);
};
row.appendChild(caret);
var icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = '\u{1F4C1}'; /* folder emoji */
row.appendChild(icon);
var name = document.createElement('span');
name.className = 'folder-name';
name.textContent = node.name + ' (' + node.count + ')';
row.appendChild(name);
/* Per-folder actions. */
var actions = document.createElement('span');
actions.className = 'folder-actions';
if (haveSigner) {
var addHere = document.createElement('a');
addHere.className = 'btn';
addHere.href = '#';
addHere.textContent = 'Add Here';
addHere.onclick = function(e) {
e.preventDefault();
document.getElementById('bm-path').value = node.path;
document.getElementById('bm-url').focus();
};
actions.appendChild(addHere);
var newSub = document.createElement('a');
newSub.className = 'btn';
newSub.href = '#';
newSub.textContent = 'New Subfolder';
newSub.onclick = function(e) {
e.preventDefault();
var sub = prompt('New subfolder name under "' + node.path + '/":');
if (sub && sub.trim()) {
var newPath = node.path + '/' + sub.trim();
location.href = 'sovereign://bookmarks/createdir?name=' +
encodeURIComponent(newPath);
}
};
actions.appendChild(newSub);
if (node.path !== 'General' && node.path !== 'Bookmarks Bar') {
var rename = document.createElement('a');
rename.className = 'btn';
rename.href = '#';
rename.textContent = 'Rename';
rename.onclick = function(e) {
e.preventDefault();
var newName = prompt('Rename folder "' + node.path + '" to:',
node.path);
if (newName && newName.trim() && newName.trim() !== node.path) {
location.href = 'sovereign://bookmarks/renamedir?old=' +
encodeURIComponent(node.path) + '&new=' +
encodeURIComponent(newName.trim());
}
};
actions.appendChild(rename);
var del = document.createElement('a');
del.className = 'btn btn-del';
del.href = '#';
del.textContent = 'Delete';
del.onclick = function(e) {
e.preventDefault();
if (confirm('Delete folder "' + node.path + '"? ' +
'Bookmarks will be moved to General.')) {
location.href = 'sovereign://bookmarks/deletedir?dir=' +
encodeURIComponent(node.path);
}
};
actions.appendChild(del);
}
}
row.appendChild(actions);
wrap.appendChild(row);
/* Child list: bookmarks + subfolders. */
var childList = document.createElement('div');
childList.className = 'tree-children';
childList.style.display = isExpanded ? '' : 'none';
childList.style.marginLeft = '20px';
/* Bookmarks in this folder. */
(node.bookmarks || []).forEach(function(bm) {
childList.appendChild(renderBookmark(bm, node, haveSigner));
});
/* Subfolders. */
(node.children || []).forEach(function(child) {
childList.appendChild(renderNode(child, haveSigner, depth + 1));
});
wrap.appendChild(childList);
return wrap;
}
function renderBookmark(bm, folder, haveSigner) {
var row = document.createElement('div');
row.className = 'bm';
var info = document.createElement('div');
var title = document.createElement('div');
title.className = 'bm-title';
title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)';
info.appendChild(title);
var urlLink = document.createElement('div');
var a = document.createElement('a');
a.className = 'bm-url';
a.href = bm.url;
a.textContent = bm.url;
urlLink.appendChild(a);
info.appendChild(urlLink);
var meta = document.createElement('div');
meta.className = 'bm-meta';
meta.textContent = 'Added: ' + bm.added;
info.appendChild(meta);
row.appendChild(info);
if (haveSigner) {
var actions = document.createElement('div');
actions.className = 'actions';
var editBtn = document.createElement('a');
editBtn.className = 'btn';
editBtn.href = '#';
editBtn.textContent = 'Edit';
editBtn.onclick = function(e) {
e.preventDefault();
var curTitle = (bm.title && bm.title.length) ? bm.title : '';
var newTitle = prompt('Edit bookmark name:', curTitle);
if (newTitle === null) return;
var t = newTitle.trim();
if (t === curTitle.trim()) return;
location.href = 'sovereign://bookmarks/rename?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url) + '&title=' +
encodeURIComponent(t);
};
actions.appendChild(editBtn);
var moveBtn = document.createElement('a');
moveBtn.className = 'btn';
moveBtn.href = '#';
moveBtn.textContent = 'Move';
moveBtn.onclick = function(e) {
e.preventDefault();
var dest = prompt('Move bookmark to folder path:', folder.path);
if (dest && dest.trim() && dest.trim() !== folder.path) {
location.href = 'sovereign://bookmarks/move?from=' +
encodeURIComponent(folder.path) + '&to=' +
encodeURIComponent(dest.trim()) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(moveBtn);
var delBtn = document.createElement('a');
delBtn.className = 'btn btn-del';
delBtn.href = '#';
delBtn.textContent = 'Delete';
delBtn.onclick = function(e) {
e.preventDefault();
if (confirm('Delete this bookmark?')) {
location.href = 'sovereign://bookmarks/delete?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(delBtn);
row.appendChild(actions);
}
return row;
}
/* ── Fetch + actions ───────────────────────────────────────────────── */
function fetchBookmarks() {
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
.then(function(data) { renderBookmarks(data); })
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('bm-list').innerHTML =
document.getElementById('bm-tree').innerHTML =
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
});
}
@@ -100,15 +285,15 @@ function fetchBookmarks() {
function addBookmark() {
var url = encodeURIComponent(document.getElementById('bm-url').value);
var title = encodeURIComponent(document.getElementById('bm-title').value);
var dir = encodeURIComponent(document.getElementById('bm-dir').value);
var path = encodeURIComponent(document.getElementById('bm-path').value);
if (!url) { alert('URL is required'); return; }
location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url +
location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url +
'&title=' + title;
}
function createDir() {
var name = encodeURIComponent(document.getElementById('new-dir').value);
if (!name) { alert('Directory name is required'); return; }
var name = encodeURIComponent(document.getElementById('new-path').value);
if (!name) { alert('Folder path is required'); return; }
location.href = 'sovereign://bookmarks/createdir?name=' + name;
}

193
www/fips.css Normal file
View File

@@ -0,0 +1,193 @@
/* FIPS management page — sovereign://fips
* Imports the shared sovereign:// base theme, then adds FIPS-specific
* layout for status cards, the peers table, and the add-peer form. */
@import url("sovereign://sovereign-base.css");
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin: 10px 0 20px;
}
.card .actions {
margin-top: 12px;
}
.mono {
font-family: var(--font);
word-break: break-all;
}
.err-text {
color: var(--accent);
}
/* State indicator dot. */
.state-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
background: var(--muted);
}
.state-dot.ready { background: var(--primary); }
.state-dot.failed { background: var(--accent); }
.state-dot.starting,
.state-dot.stopping,
.state-dot.bootstrapping,
.state-dot.discovering,
.state-dot.attaching { background: var(--accent); animation: pulse 1s infinite; }
/* Peers table. */
#peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#peers-table th,
#peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#peers-table td.mono { word-break: break-all; }
.peer-state-connected { color: var(--primary); }
.peer-state-connecting,
.peer-state-disconnected { color: var(--muted); }
.peer-state-failed { color: var(--accent); }
#refresh-btn { margin-left: 12px; }
/* Tab navigation. */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Known-nodes / tree-peers tables. */
#nodes-table, #tree-peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#nodes-table th, #nodes-table td,
#tree-peers-table th, #tree-peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#nodes-table th, #tree-peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#nodes-table td.mono, #tree-peers-table td.mono { word-break: break-all; }
/* Direct-peer badge in the known-nodes table. */
.peer-badge {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--primary);
}
.peer-badge.indirect { background: var(--muted); }
/* Staleness coloring for last-seen. */
.last-seen-fresh { color: var(--primary); }
.last-seen-stale { color: var(--muted); }
.last-seen-old { color: var(--muted); font-style: italic; }
/* Tree role tags. */
.tree-role { font-size: 11px; padding: 1px 6px; border-radius: 3px;
border: 1px solid var(--border); }
.tree-role.root { color: var(--accent); border-color: var(--accent); }
.tree-role.parent { color: var(--primary); }
.tree-role.child { color: var(--muted); }
/* Directory tab — compact one-line rows, wider table, smaller font. */
body.wide { max-width: 1400px; }
#tab-directory { max-width: 100%; }
#dir-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
table-layout: fixed;
}
#dir-table th, #dir-table td {
text-align: left;
padding: 3px 8px;
border-bottom: 1px solid var(--border);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#dir-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
/* Column widths: badge | npub | endpoint | connect */
#dir-table th:nth-child(1), #dir-table td:nth-child(1) { width: 20px; }
#dir-table th:nth-child(3), #dir-table td:nth-child(3) { width: 35%; }
#dir-table th:nth-child(4), #dir-table td:nth-child(4) { width: 80px; }
#dir-table td.mono {
font-family: var(--font);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Pinned "this is you" row — highlighted border. */
#dir-table tr.dir-ours-row {
border: 2px solid var(--accent);
background: rgba(255, 0, 0, 0.05);
}
#dir-table tr.dir-ours-row td {
border-bottom: 2px solid var(--accent);
font-weight: bold;
}
.dir-ours-tag {
color: var(--accent);
font-size: 10px;
font-weight: bold;
border: 1px solid var(--accent);
padding: 1px 4px;
border-radius: 3px;
}
/* Connect as clickable text, not a button. */
.dir-connect-link {
color: var(--accent);
font-size: 11px;
cursor: pointer;
text-decoration: underline;
}
.dir-connect-link:hover { text-decoration: none; }

160
www/fips.html Normal file
View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FIPS Mesh Network</title>
<link rel="stylesheet" href="sovereign://fips.css">
</head>
<body>
<h1>FIPS Mesh Network <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="status">Status</button>
<button class="tab-btn" data-tab="peers">Peers</button>
<button class="tab-btn" data-tab="network">Network</button>
<button class="tab-btn" data-tab="tree">Tree</button>
<button class="tab-btn" data-tab="directory">Directory</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Status tab -->
<div class="tab-panel active" id="tab-status">
<h2>Status</h2>
<div class="card" id="fips-status">
<div class="info-row"><span>State</span><span id="fips-state"></span></div>
<div class="info-row"><span>Node</span><span id="fips-node" class="mono"></span></div>
<div class="info-row"><span>TUN</span><span id="fips-tun"></span></div>
<div class="info-row"><span>Peers</span><span id="fips-peers"></span></div>
<div class="info-row"><span>Tree</span><span id="fips-tree"></span></div>
<div class="info-row"><span>Ownership</span><span id="fips-ownership"></span></div>
<div class="info-row"><span>Control</span><span id="fips-control" class="mono"></span></div>
<div class="info-row" id="fips-error-row" style="display:none">
<span>Error</span><span id="fips-error" class="err-text"></span>
</div>
<div class="actions">
<button class="btn" id="restart-btn">Restart Service</button>
</div>
</div>
<h2>Tor Status <span class="note">(read-only)</span></h2>
<div class="card" id="tor-status">
<div class="info-row"><span>State</span><span id="tor-state"></span></div>
<div class="info-row"><span>Bootstrap</span><span id="tor-bootstrap"></span></div>
<div class="info-row"><span>SOCKS</span><span id="tor-socks" class="mono"></span></div>
<div class="info-row"><span>Ownership</span><span id="tor-ownership"></span></div>
<div class="info-row" id="tor-error-row" style="display:none">
<span>Error</span><span id="tor-error" class="err-text"></span>
</div>
</div>
</div>
<!-- Peers tab -->
<div class="tab-panel" id="tab-peers">
<h2>Peers</h2>
<div id="peers-container">
<p class="empty" id="peers-empty">Loading peers…</p>
<table id="peers-table" style="display:none">
<thead>
<tr>
<th>npub</th><th>address</th><th>transport</th><th>state</th><th></th>
</tr>
</thead>
<tbody id="peers-body"></tbody>
</table>
</div>
<h2>Add Peer</h2>
<div class="card">
<div class="form">
<input type="text" id="peer-npub" placeholder="npub1..." size="50">
<input type="text" id="peer-address" placeholder="host:port" size="30">
<select id="peer-transport">
<option value="udp">udp</option>
<option value="tcp">tcp</option>
</select>
<button class="btn" id="connect-btn">Connect</button>
</div>
</div>
</div>
<!-- Network tab -->
<div class="tab-panel" id="tab-network">
<h2>Mesh Summary</h2>
<div class="card" id="mesh-summary">
<div class="info-row"><span>Known nodes</span><span id="mesh-known"></span></div>
<div class="info-row"><span>Direct peers</span><span id="mesh-direct"></span></div>
<div class="info-row"><span>Tree depth</span><span id="mesh-depth"></span></div>
<div class="info-row"><span>Root</span><span id="mesh-root" class="mono"></span></div>
<div class="info-row"><span>Our parent</span><span id="mesh-parent"></span></div>
<div class="info-row" id="mesh-error-row" style="display:none">
<span>Error</span><span id="mesh-error" class="err-text"></span>
</div>
</div>
<h2>Known Nodes <span class="note" id="nodes-count-label"></span></h2>
<div id="nodes-container">
<p class="empty" id="nodes-empty">Loading network…</p>
<table id="nodes-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>name</th><th>ipv6</th><th>last seen</th>
</tr>
</thead>
<tbody id="nodes-body"></tbody>
</table>
</div>
</div>
<!-- Directory tab -->
<div class="tab-panel" id="tab-directory">
<h2>FIPS Node Directory <span class="note" id="dir-count-label"></span></h2>
<p class="note">All nodes that have published a FIPS service advert (Kind 37195) to the Nostr advert relays. This is the global network directory, like join.fips.network.</p>
<div id="dir-container">
<p class="empty" id="dir-empty">Click to load the directory from Nostr relays…</p>
<table id="dir-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>endpoint</th><th></th>
</tr>
</thead>
<tbody id="dir-body"></tbody>
</table>
</div>
<div class="actions" id="dir-actions" style="display:none">
<button class="btn" id="dir-refresh-btn">Reload Directory</button>
</div>
</div>
<!-- Tree tab -->
<div class="tab-panel" id="tab-tree">
<h2>Spanning Tree</h2>
<div class="card" id="tree-summary">
<div class="info-row"><span>Root</span><span id="tree-root" class="mono"></span></div>
<div class="info-row"><span>Is root</span><span id="tree-is-root"></span></div>
<div class="info-row"><span>Our depth</span><span id="tree-depth"></span></div>
<div class="info-row"><span>Our parent</span><span id="tree-parent"></span></div>
<div class="info-row"><span>Our node addr</span><span id="tree-node-addr" class="mono"></span></div>
<div class="info-row" id="tree-error-row" style="display:none">
<span>Error</span><span id="tree-error" class="err-text"></span>
</div>
</div>
<h2>Tree Peers <span class="note">(1-hop neighbors)</span></h2>
<div id="tree-peers-container">
<p class="empty" id="tree-peers-empty">Loading tree…</p>
<table id="tree-peers-table" style="display:none">
<thead>
<tr>
<th>node addr</th><th>name</th><th>depth</th><th>distance</th><th>role</th>
</tr>
</thead>
<tbody id="tree-peers-body"></tbody>
</table>
</div>
</div>
<script src="sovereign://fips.js"></script>
</body>
</html>

564
www/fips.js Normal file
View File

@@ -0,0 +1,564 @@
/* FIPS management page — sovereign://fips
*
* Tabs: Status | Peers | Network | Tree
*
* - Status: FIPS + Tor status cards (sovereign://fips/status)
* - Peers: direct peers table + add-peer form (sovereign://fips/peers)
* - Network: mesh summary + known-nodes table (sovereign://fips/network)
* - Tree: spanning tree view (sovereign://fips/tree)
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
function stateDotClass(state) {
return 'state-dot ' + (state || '');
}
/* Global: our own FIPS node npub (from show_status), used to pin our
* node in the Directory tab. */
var gOurNpub = '';
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function(panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
/* Widen the page for the directory tab (long npub list needs width). */
document.body.classList.toggle('wide', tabName === 'directory');
/* Load tab data on demand. */
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
if (tabName === 'directory') fetchDirectory();
}
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
});
/* ── Status tab ─────────────────────────────────────────────────── */
function renderFipsStatus(data) {
var f = data.fips || {};
var state = f.state || 'disabled';
/* Store our npub for the Directory tab pinning. */
gOurNpub = f.node_npub || '';
document.getElementById('fips-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('fips-node').textContent = f.node_npub || '—';
var tun = '—';
if (f.tun_name && f.tun_name.length) {
tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)');
}
document.getElementById('fips-tun').textContent = tun;
document.getElementById('fips-peers').textContent =
(f.peer_count != null ? f.peer_count : '—') + ' connected';
document.getElementById('fips-tree').textContent = f.tree_state || '—';
document.getElementById('fips-ownership').textContent =
f.ownership || '—';
document.getElementById('fips-control').textContent =
f.control_socket || '—';
var errRow = document.getElementById('fips-error-row');
var errEl = document.getElementById('fips-error');
if (f.error && f.error.length) {
errEl.textContent = f.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function renderTorStatus(data) {
var t = data.tor || {};
var state = t.state || 'disabled';
document.getElementById('tor-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('tor-bootstrap').textContent =
(t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%';
document.getElementById('tor-socks').textContent =
t.socks_endpoint || '—';
document.getElementById('tor-ownership').textContent =
t.ownership || '—';
var errRow = document.getElementById('tor-error-row');
var errEl = document.getElementById('tor-error');
if (t.error && t.error.length) {
errEl.textContent = t.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function fetchStatus() {
return sovereignGet('sovereign://fips/status?_=' + Date.now())
.then(function(data) {
renderFipsStatus(data);
renderTorStatus(data);
})
.catch(function(e) {
showMsg('Failed to load status: ' + e.message, true);
});
}
/* ── Peers tab ──────────────────────────────────────────────────── */
function renderPeers(data) {
var body = document.getElementById('peers-body');
var table = document.getElementById('peers-table');
var empty = document.getElementById('peers-empty');
var peers = data.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'No peers (' + data.error + ')'
: 'No peers connected.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var html = '';
peers.forEach(function(p) {
var npub = p.npub || p.id || '';
var addr = p.transport_addr || p.address || p.addr || '';
var transport = p.transport_type || p.transport || '';
var state = p.connectivity || p.state || p.status || '';
var label = p.display_name || '';
html += '<tr>';
html += '<td class="mono">' + esc(npub) +
(label ? '<br><span class="note">' + esc(label) + '</span>' : '') +
'</td>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(transport) + '</td>';
html += '<td class="peer-state-' + esc(state) + '">' + esc(state) + '</td>';
html += '<td><button class="btn btn-del" data-npub="' + esc(npub) +
'" onclick="disconnectPeer(\'' + esc(npub).replace(/'/g, "\\'") +
'\')">✕</button></td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchPeers() {
return sovereignGet('sovereign://fips/peers?_=' + Date.now())
.then(function(data) { renderPeers(data); })
.catch(function(e) {
document.getElementById('peers-empty').textContent =
'Failed to load peers: ' + e.message;
});
}
function connectPeer() {
var npub = document.getElementById('peer-npub').value.trim();
var address = document.getElementById('peer-address').value.trim();
var transport = document.getElementById('peer-transport').value;
if (!npub || !address) {
showMsg('npub and address are required', true);
return;
}
var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) +
'&address=' + encodeURIComponent(address) +
'&transport=' + encodeURIComponent(transport);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Connect failed: ' + d.error, true);
} else {
showMsg('Peer added');
document.getElementById('peer-npub').value = '';
document.getElementById('peer-address').value = '';
fetchPeers();
}
})
.catch(function(e) { showMsg('Connect failed: ' + e.message, true); });
}
function disconnectPeer(npub) {
if (!npub) return;
if (!confirm('Disconnect peer ' + npub + '?')) return;
var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Disconnect failed: ' + d.error, true);
} else {
showMsg('Peer disconnected');
fetchPeers();
}
})
.catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); });
}
function restartService() {
if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return;
showMsg('Restarting FIPS service…');
sovereignGet('sovereign://fips/restart')
.then(function(d) {
if (d.error) showMsg('Restart failed: ' + d.error, true);
else showMsg('Restart initiated');
})
.catch(function(e) { showMsg('Restart failed: ' + e.message, true); });
}
/* ── Network tab ────────────────────────────────────────────────── */
function formatAge(ageMs) {
if (ageMs == null) return '—';
var s = Math.floor(ageMs / 1000);
if (s < 60) return s + 's ago';
var m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
var h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
function ageClass(ageMs) {
if (ageMs == null) return '';
if (ageMs < 10000) return 'last-seen-fresh';
if (ageMs < 60000) return 'last-seen-stale';
return 'last-seen-old';
}
function renderNetwork(data) {
var s = data.summary || {};
var nodes = data.nodes || [];
/* Mesh summary. */
document.getElementById('mesh-known').textContent =
(s.known_nodes != null ? s.known_nodes : '—') +
(s.max_cache_entries != null ? ' / ' + s.max_cache_entries : '');
document.getElementById('mesh-direct').textContent =
(data.peer_count != null ? data.peer_count : '—');
document.getElementById('mesh-depth').textContent =
(s.tree_depth != null ? s.tree_depth : '—');
document.getElementById('mesh-root').textContent =
s.root_npub ? esc(s.root_npub) : '—';
document.getElementById('mesh-parent').textContent =
s.parent_display_name ? esc(s.parent_display_name) :
(s.is_root ? '(this node is root)' : '—');
var errRow = document.getElementById('mesh-error-row');
var errEl = document.getElementById('mesh-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
/* Known nodes table. */
var body = document.getElementById('nodes-body');
var table = document.getElementById('nodes-table');
var empty = document.getElementById('nodes-empty');
var countLabel = document.getElementById('nodes-count-label');
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Network unavailable: ' + data.error
: 'No known nodes (FIPS may not be ready).';
return;
}
/* Sort by last_seen descending (most recent first). */
nodes.sort(function(a, b) {
var aT = a.last_seen_ms || 0;
var bT = b.last_seen_ms || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var name = n.display_name || '—';
var ipv6 = n.ipv6_addr || '';
var age = n.age_ms != null ? n.age_ms : null;
var direct = n.is_direct_peer;
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'discovery only') + '"></span>';
html += '<tr>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td class="mono">' + esc(ipv6) + '</td>';
html += '<td class="' + ageClass(age) + '">' + formatAge(age) + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchNetwork() {
return sovereignGet('sovereign://fips/network?_=' + Date.now())
.then(function(data) { renderNetwork(data); })
.catch(function(e) {
document.getElementById('nodes-empty').textContent =
'Failed to load network: ' + e.message;
});
}
/* ── Tree tab ───────────────────────────────────────────────────── */
function renderTree(data) {
var t = data.tree || {};
var errRow = document.getElementById('tree-error-row');
var errEl = document.getElementById('tree-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
document.getElementById('tree-peers-empty').textContent =
'Tree unavailable: ' + data.error;
document.getElementById('tree-peers-table').style.display = 'none';
/* Clear summary. */
['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr']
.forEach(function(id) { document.getElementById(id).textContent = '—'; });
return;
}
errRow.style.display = 'none';
document.getElementById('tree-root').textContent =
t.root_npub || t.root || '—';
document.getElementById('tree-is-root').textContent =
t.is_root ? 'yes' : 'no';
document.getElementById('tree-depth').textContent =
(t.depth != null ? t.depth : '—');
document.getElementById('tree-parent').textContent =
t.parent_display_name || t.parent || '—';
document.getElementById('tree-node-addr').textContent =
t.my_node_addr || '—';
/* Tree peers (1-hop neighbors). */
var body = document.getElementById('tree-peers-body');
var table = document.getElementById('tree-peers-table');
var empty = document.getElementById('tree-peers-empty');
var peers = t.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = 'No tree peers.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var myAddr = t.my_node_addr || '';
var parentAddr = t.parent || '';
var html = '';
peers.forEach(function(p) {
var addr = p.node_addr || '';
var name = p.display_name || '—';
var depth = p.depth != null ? p.depth : '—';
var dist = p.distance_to_us != null ? p.distance_to_us : '—';
var role = '';
if (addr === parentAddr && !t.is_root) {
role = '<span class="tree-role parent">parent</span>';
} else if (addr === t.root) {
role = '<span class="tree-role root">root</span>';
} else {
role = '<span class="tree-role child">peer</span>';
}
html += '<tr>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td>' + esc(depth) + '</td>';
html += '<td>' + esc(dist) + '</td>';
html += '<td>' + role + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchTree() {
return sovereignGet('sovereign://fips/tree?_=' + Date.now())
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('tree-peers-empty').textContent =
'Failed to load tree: ' + e.message;
});
}
/* ── Directory tab ───────────────────────────────────────────────── */
function renderDirectory(data) {
var body = document.getElementById('dir-body');
var table = document.getElementById('dir-table');
var empty = document.getElementById('dir-empty');
var countLabel = document.getElementById('dir-count-label');
var actions = document.getElementById('dir-actions');
var nodes = data.nodes || [];
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
actions.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Directory unavailable: ' + data.error
: 'No advertised nodes found on the advert relays.';
return;
}
/* Sort: our own node first (pinned), then by created_at descending. */
nodes.sort(function(a, b) {
var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0;
var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0;
if (aOurs !== bOurs) return bOurs - aOurs;
var aT = a.created_at || 0;
var bT = b.created_at || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
actions.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var direct = n.is_direct_peer;
var isOurs = (npub && npub === gOurNpub);
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'not connected') + '"></span>';
/* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */
var endpointsStr = '—';
if (n.endpoints && n.endpoints.length) {
var ep = n.endpoints[0];
endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || '');
if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1);
}
/* Connect: clickable text, not a button. */
var firstEp = (n.endpoints && n.endpoints[0]) || {};
var addr = firstEp.addr || '';
var transport = firstEp.transport || 'udp';
var connectLink = '';
if (isOurs) {
connectLink = '<span class="dir-ours-tag">this is you</span>';
} else if (addr && !direct) {
connectLink = '<a class="dir-connect-link" href="javascript:void(0)" onclick="connectFromDirectory(\'' +
esc(npub).replace(/'/g, "\\'") + '\', \'' +
esc(addr).replace(/'/g, "\\'") + '\', \'' +
esc(transport) + '\')">connect</a>';
} else if (direct) {
connectLink = '<span class="note">connected</span>';
}
var rowClass = isOurs ? ' class="dir-ours-row"' : '';
html += '<tr' + rowClass + '>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td class="mono">' + endpointsStr + '</td>';
html += '<td>' + connectLink + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchDirectory() {
var empty = document.getElementById('dir-empty');
empty.style.display = '';
empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…';
document.getElementById('dir-table').style.display = 'none';
document.getElementById('dir-actions').style.display = 'none';
return sovereignGet('sovereign://fips/directory?_=' + Date.now())
.then(function(data) { renderDirectory(data); })
.catch(function(e) {
empty.textContent = 'Failed to load directory: ' + e.message;
});
}
function connectFromDirectory(npub, address, transport) {
/* Switch to the Peers tab, pre-fill the add-peer form, and connect. */
switchTab('peers');
document.getElementById('peer-npub').value = npub;
document.getElementById('peer-address').value = address;
var sel = document.getElementById('peer-transport');
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === transport) {
sel.selectedIndex = i;
break;
}
}
connectPeer();
}
/* ── Refresh + auto-refresh ─────────────────────────────────────── */
function refresh() {
showMsg('');
var activeTab = document.querySelector('.tab-btn.active');
var tabName = activeTab ? activeTab.dataset.tab : 'status';
var promises = [fetchStatus(), fetchPeers()];
if (tabName === 'network') promises.push(fetchNetwork());
if (tabName === 'tree') promises.push(fetchTree());
Promise.all(promises).then(function() {});
}
document.getElementById('refresh-btn').addEventListener('click', refresh);
document.getElementById('connect-btn').addEventListener('click', connectPeer);
document.getElementById('restart-btn').addEventListener('click', restartService);
var dirRefreshBtn = document.getElementById('dir-refresh-btn');
if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory);
/* Initial load. */
refresh();
/* Auto-refresh status + peers every 5s. Network/tree refresh on
* tab activation and every 10s when active. */
setInterval(function() {
fetchStatus();
fetchPeers();
}, 5000);
setInterval(function() {
var activeTab = document.querySelector('.tab-btn.active');
if (!activeTab) return;
var tabName = activeTab.dataset.tab;
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
}, 10000);

369
www/js/perf-probe.js Normal file
View File

@@ -0,0 +1,369 @@
/*
* perf-probe.js — per-tab performance probe for sovereign_browser
*
* Injected by WebKitUserContentManager into every non-sovereign:// page
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
* - resource entries (PerformanceObserver 'resource') -> net counts
* - requestAnimationFrame cadence -> fps
* - patched setTimeout/setInterval -> active timer count + top intervals
* - patched addEventListener -> event listener count
* - patched Worker -> worker count
* - performance.memory -> heap used
* - document.getElementsByTagName('*').length -> DOM node count
*
* Batches a report every 1s and POSTs it to:
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
*
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
* tag injected before this script runs (see inject_perf_probe() in
* nostr_bridge.c / main.c). If absent, the report is dropped.
*
* The probe is a no-op on sovereign:// pages (the injector skips them)
* to avoid self-noise and recursion.
*/
(function () {
'use strict';
// Honor the skip flag set by the preamble on sovereign:// / about:
// pages. The skip flag is re-evaluated on every navigation (the
// preamble runs at document_start for each new document), so we also
// reset our installed flag here to allow re-injection after a
// navigation from an internal page to a normal one.
if (window.__sbPerfProbeSkip) return;
if (window.__sbPerfProbe) return; // already running in this document
window.__sbPerfProbe = true;
// Tab index is injected by the browser via window.__sbPerfTabIndex
// (set by the preamble that runs before this script). Read it once.
var TAB_INDEX = window.__sbPerfTabIndex;
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
// Capture the native timers early — the heartbeat (below) needs
// origSetTimeout before the timer-patching block runs.
var origSetTimeout = window.setTimeout;
var origSetInterval = window.setInterval;
var origClearTimeout = window.clearTimeout;
var origClearInterval = window.clearInterval;
// ── Long tasks ──────────────────────────────────────────────────
var longTasks = []; // last 30s of {t, duration, name}
var longTasksLastSec = []; // tasks in the current 1s window
var longTaskSources = {}; // name -> {total_ms, count}
var longTaskSupported = false;
if (typeof PerformanceObserver !== 'undefined') {
try {
var ltObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
var rec = {
t: performance.now(),
duration: e.duration,
name: e.name || 'unknown',
attribution: (e.attribution && e.attribution[0])
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
: ''
};
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = rec.name || 'unknown';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += rec.duration;
longTaskSources[key].count++;
});
});
ltObs.observe({ entryTypes: ['longtask'] });
longTaskSupported = true;
} catch (e) { /* longtask not supported */ }
}
// ── Main-thread blockage fallback ───────────────────────────────
// WebKitGTK does not fire 'longtask' entries, so we measure main-
// thread blockage directly: schedule a 0ms heartbeat and measure
// how late it fires. If the main thread is blocked by a busy loop,
// the heartbeat is delayed. The total delay in a 1s window is a
// proxy for cpu_busy_percent.
var heartbeatDelayMs = 0; // accumulated delay in the current window
var heartbeatLast = performance.now();
function heartbeat() {
var now = performance.now();
var expected = heartbeatLast + 50; // we schedule every 50ms
var delay = now - expected;
if (delay > 5) { // ignore small scheduler jitter
heartbeatDelayMs += delay;
// Record as a synthetic long task if > 50ms.
if (delay > 50) {
var rec = { t: now, duration: delay, name: 'main-thread-block',
attribution: '' };
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = 'main-thread-block';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += delay;
longTaskSources[key].count++;
}
}
heartbeatLast = now;
origSetTimeout.call(window, heartbeat, 50);
}
// Always start the heartbeat. On engines where longtask fires
// reliably, the heartbeat delay will be near-zero (long tasks are
// captured by the observer). On WebKitGTK, where longtask is
// accepted but never fires, the heartbeat is the sole source of
// cpu_busy measurement. The report() function takes the max of
// both, so there's no double-counting.
origSetTimeout.call(window, heartbeat, 50);
// ── Resource entries (network) ────────────────────────────────
var netRequestsLastSec = [];
var netEndpoints = {}; // url-prefix -> count
if (typeof PerformanceObserver !== 'undefined') {
try {
var resObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
var key = e.name.split('?')[0];
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
});
});
resObs.observe({ entryTypes: ['resource'] });
} catch (e) { /* resource not supported */ }
}
// ── FPS via requestAnimationFrame ───────────────────────────────
var rafFrames = 0;
var fps = 0;
function rafLoop(ts) {
rafFrames++;
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
var activeTimers = {}; // id -> {code, interval_ms, count}
var timerTop = {}; // "code|interval" -> count
var nextTimerId = 1;
function labelFor(fn) {
try {
var s = (fn && fn.name) ? fn.name : 'anonymous';
// Truncate long inline-function bodies.
if (s.length > 40) s = s.slice(0, 40);
return s;
} catch (e) { return 'anonymous'; }
}
// origSetTimeout / origSetInterval / origClearTimeout /
// origClearInterval were captured at the top of the IIFE (before the
// heartbeat was installed), so they reference the native timers.
window.setTimeout = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
var key = label + '|0';
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
delete activeTimers[id];
return fn.apply(this, args);
};
return origSetTimeout.call(this, wrapped, delay);
};
window.setInterval = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
var interval = delay | 0;
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
var key = label + '|' + interval;
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
if (activeTimers[id]) activeTimers[id].count++;
return fn.apply(this, args);
};
return origSetInterval.call(this, wrapped, delay);
};
window.clearTimeout = function (id) {
delete activeTimers[id];
return origClearTimeout.call(this, id);
};
window.clearInterval = function (id) {
delete activeTimers[id];
return origClearInterval.call(this, id);
};
// ── Event listener count (patched addEventListener) ─────────────
var eventListenerCount = 0;
var origAEL = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
// Skip our own internal listeners (tagged with __sbInternal).
if (!fn || !fn.__sbInternal) eventListenerCount++;
return origAEL.call(this, type, fn, opts);
};
// ── Worker count (patched Worker constructor) ───────────────────
var workerCount = 0;
var OrigWorker = window.Worker;
if (OrigWorker) {
window.Worker = function (url, opts) {
workerCount++;
return new OrigWorker(url, opts);
};
window.Worker.prototype = OrigWorker.prototype;
}
// ── In-flight network (patched fetch + XHR.open) ────────────────
var netInFlight = 0;
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function () {
netInFlight++;
var p = origFetch.apply(this, arguments);
var dec = function () { if (netInFlight > 0) netInFlight--; };
p.then(dec, dec);
return p;
};
}
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
netInFlight++;
var onEnd = function () {
if (netInFlight > 0) netInFlight--;
};
onEnd.__sbInternal = true;
this.addEventListener('loadend', onEnd);
return origXhrOpen.apply(this, arguments);
};
// ── Reporting ───────────────────────────────────────────────────
function report() {
// Trim long-task timeline to last 30s.
var now = performance.now();
while (longTasks.length && (now - longTasks[0].t) > 30000) {
longTasks.shift();
}
// cpu_busy_percent: sum of long-task durations in the last 1s
// window. If the longtask PerformanceObserver isn't supported
// (WebKitGTK), fall back to the heartbeat-delay measurement.
var busyMs = 0;
for (var i = 0; i < longTasksLastSec.length; i++) {
busyMs += longTasksLastSec[i].duration;
}
// If the heartbeat measured more blockage than the longtask
// observer (e.g. on WebKitGTK where longtask never fires), use
// the heartbeat measurement.
if (heartbeatDelayMs > busyMs) {
busyMs = heartbeatDelayMs;
}
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
if (cpuBusy > 100) cpuBusy = 100;
heartbeatDelayMs = 0; // reset for next window
// Top timer sources.
var timerTopArr = [];
Object.keys(timerTop).forEach(function (k) {
var parts = k.split('|');
timerTopArr.push({
code: parts[0],
interval_ms: parseInt(parts[1], 10) || 0,
count: timerTop[k]
});
});
timerTopArr.sort(function (a, b) { return b.count - a.count; });
timerTopArr = timerTopArr.slice(0, 5);
// Top network endpoints (last window).
var netTopArr = [];
Object.keys(netEndpoints).forEach(function (u) {
netTopArr.push({ url: u, count: netEndpoints[u] });
});
netTopArr.sort(function (a, b) { return b.count - a.count; });
netTopArr = netTopArr.slice(0, 5);
// Top long-task sources.
var ltSourcesArr = [];
Object.keys(longTaskSources).forEach(function (k) {
ltSourcesArr.push({
name: k,
total_ms: Math.round(longTaskSources[k].total_ms),
count: longTaskSources[k].count
});
});
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
ltSourcesArr = ltSourcesArr.slice(0, 8);
// Heap.
var heapMb = -1;
if (performance.memory && performance.memory.usedJSHeapSize) {
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
}
// DOM node count.
var domNodes = -1;
try {
domNodes = document.getElementsByTagName('*').length;
} catch (e) { /* document not ready */ }
var payload = {
tab_index: TAB_INDEX,
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
fps: fps,
timer_count: Object.keys(activeTimers).length,
timer_top: timerTopArr,
net_in_flight: netInFlight,
net_requests_last_sec: netRequestsLastSec.length,
net_top_endpoints: netTopArr,
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
event_listener_count: eventListenerCount,
worker_count: workerCount,
dom_node_count: domNodes,
long_tasks_last_sec: longTasksLastSec.map(function (r) {
return {
duration: Math.round(r.duration),
name: r.name,
attribution: r.attribution
};
}),
long_task_top_sources: ltSourcesArr,
long_task_timeline: longTasks.map(function (r) {
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
})
};
// Reset per-window accumulators.
longTasksLastSec = [];
netRequestsLastSec = [];
netEndpoints = {};
timerTop = {};
// Send. Use sync XHR to sovereign:// (same convention as the
// window.nostr shim — WebKit's custom scheme handler doesn't
// reliably support fetch() or async XHR for sovereign:// URLs).
try {
var body = encodeURIComponent(JSON.stringify(payload));
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
'&body=' + body;
var x = new XMLHttpRequest();
x.open('GET', url, false); // synchronous
x.send();
} catch (e) { /* swallow — probe must never break the page */ }
}
// FPS counter: count frames in each 1s window.
setInterval(function () {
fps = rafFrames;
rafFrames = 0;
}, 1000);
// Report every 1s. Use the original setInterval so we don't track
// ourselves in activeTimers.
origSetInterval.call(window, report, 1000);
})();

144
www/processes.css Normal file
View File

@@ -0,0 +1,144 @@
/* Processes page — sovereign://processes
* Imports the shared sovereign:// base theme, then adds processes-specific
* layout for the process/tab tables, heat bars, and drill-down panel. */
@import url("sovereign://sovereign-base.css");
.note { font-size: 12px; font-weight: normal; }
code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; }
.url-sub { font-size: 11px; }
.dim { color: var(--muted); }
/* Tab navigation (shared with fips.css pattern). */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
#refresh-btn { margin-left: 12px; }
/* Process + tab tables. */
#proc-table, #tabs-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#proc-table th, #proc-table td,
#tabs-table th, #tabs-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
white-space: nowrap;
}
#proc-table th, #tabs-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
cursor: pointer;
user-select: none;
}
#proc-table th:hover, #tabs-table th:hover { color: var(--primary); }
#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#proc-table td.title-cell,
#tabs-table td.title-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
#proc-table td.url-cell,
#tabs-table td.url-cell {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
}
/* CPU values: plain text, no shaded background. Red only for >= 90%,
* otherwise the default foreground color. */
.heat {
font-weight: bold;
color: var(--fg);
}
.heat.hot { color: var(--accent); }
/* Renderer row: clickable to expand hosted tabs. */
.renderer-row { cursor: pointer; }
.renderer-row:hover { background: var(--border); }
.hosted-tabs-row td { padding-left: 24px; color: var(--muted); }
.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; }
.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; }
/* Drill-down panel. */
#drilldown {
margin-top: 24px;
border-top: 2px solid var(--primary);
padding-top: 12px;
}
#drilldown h2 { margin-bottom: 8px; }
#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; }
.sub-table {
width: 100%;
border-collapse: collapse;
margin: 6px 0 12px;
}
.sub-table th, .sub-table td {
text-align: left;
padding: 4px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.sub-table th { color: var(--muted); font-weight: bold; }
.sub-table td.mono { font-family: var(--font); word-break: break-all; }
/* Long-task timeline: a horizontal bar of vertical lines, one per
* long task, height scaled by duration. */
.timeline {
height: 80px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
overflow: hidden;
position: relative;
background: var(--bg);
}
.timeline .tl-bar {
display: inline-block;
width: 2px;
margin-right: 1px;
background: var(--accent);
vertical-align: bottom;
}
.timeline .tl-empty {
color: var(--muted);
font-size: 12px;
padding: 4px 8px;
}
.actions { margin-top: 16px; }
.actions .btn { margin-right: 8px; }
.empty { color: var(--muted); font-style: italic; }
.mono { font-family: var(--font); word-break: break-all; }

110
www/processes.html Normal file
View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Processes — Sovereign Browser</title>
<link rel="stylesheet" href="sovereign://processes.css">
</head>
<body>
<h1>Processes <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="processes">Processes</button>
<button class="tab-btn" data-tab="tabs">Tabs</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Processes tab -->
<div class="tab-panel active" id="tab-processes">
<p class="note">
All sovereign_browser processes: the main browser, WebKit renderer
/ network / GPU processes, and managed Tor/FIPS subprocesses.
CPU% is computed from <code>/proc</code> utime+stime deltas
(first sample is always 0). Renderer rows expand to show their
hosted tabs.
</p>
<table id="proc-table">
<thead>
<tr>
<th data-sort="pid">PID</th>
<th data-sort="name">Name</th>
<th data-sort="cpu_percent">CPU%</th>
<th data-sort="rss_kb">RSS</th>
<th data-sort="pss_kb">PSS</th>
<th data-sort="threads">Threads</th>
<th data-sort="uptime_sec">Uptime</th>
<th data-sort="state">State</th>
<th data-sort="ownership">Type</th>
<th>Tabs</th>
</tr>
</thead>
<tbody id="proc-body"></tbody>
</table>
</div>
<!-- Tabs tab -->
<div class="tab-panel" id="tab-tabs">
<p class="note">
Per-tab performance from the in-page probe. <b>CPU-busy%</b> is
the fraction of the last second spent in long (>50ms)
main-thread tasks — the direct measure of which tab is hogging
CPU. Click a row for a drill-down (long-task sources, timers,
network endpoints, heap, workers).
</p>
<table id="tabs-table">
<thead>
<tr>
<th data-sort="index">#</th>
<th data-sort="title">Title</th>
<th data-sort="cpu_busy_percent">CPU-busy%</th>
<th data-sort="fps">FPS</th>
<th data-sort="timer_count">Timers</th>
<th data-sort="net_in_flight">Net</th>
<th data-sort="heap_used_mb">Heap</th>
<th data-sort="dom_node_count">DOM</th>
<th data-sort="webprocess_pid">WPID</th>
</tr>
</thead>
<tbody id="tabs-body"></tbody>
</table>
<!-- Drill-down panel -->
<div id="drilldown" style="display:none">
<h2 id="dd-title">Tab detail</h2>
<div class="card" id="dd-summary"></div>
<h3>Long-task sources <span class="note">(top scripts by total ms)</span></h3>
<table class="sub-table" id="dd-lt-sources">
<thead><tr><th>name</th><th>total ms</th><th>count</th></tr></thead>
<tbody id="dd-lt-sources-body"></tbody>
</table>
<h3>Active timers <span class="note">(top by frequency)</span></h3>
<table class="sub-table" id="dd-timers">
<thead><tr><th>code</th><th>interval ms</th><th>count</th></tr></thead>
<tbody id="dd-timers-body"></tbody>
</table>
<h3>Network endpoints <span class="note">(top by request count)</span></h3>
<table class="sub-table" id="dd-net">
<thead><tr><th>url</th><th>count</th></tr></thead>
<tbody id="dd-net-body"></tbody>
</table>
<h3>Long-task timeline <span class="note">(last 30s)</span></h3>
<div id="dd-timeline" class="timeline"></div>
<div class="actions">
<button class="btn" id="dd-reload">Reload tab</button>
<button class="btn" id="dd-suspend">Suspend tab</button>
<button class="btn" id="dd-close">Close tab</button>
<button class="btn" id="dd-close-panel">Close panel</button>
</div>
</div>
</div>
<script src="sovereign://processes.js"></script>
</body>
</html>

451
www/processes.js Normal file
View File

@@ -0,0 +1,451 @@
/* Processes page — sovereign://processes
*
* Tabs: Processes | Tabs
*
* - Processes: /proc-level view of all sovereign_browser PIDs (main,
* WebKit renderers with their hosted tabs, WebKit network/gpu, Tor,
* FIPS). Polled from sovereign://processes/list every 1s. Sortable
* columns. Renderer rows expand to show hosted tabs.
* - Tabs: per-tab probe view (cpu_busy_percent, fps, timers, net, heap,
* DOM, WPID). Polled from sovereign://processes/tabs every 1s.
* Click a row to load the drill-down (sovereign://processes/tab_probe).
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function (resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function () {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function () { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
/* ── Formatting helpers ─────────────────────────────────────────── */
function fmtKb(kb) {
if (kb == null || kb < 0) return '—';
if (kb >= 1024 * 1024) return (kb / (1024 * 1024)).toFixed(1) + 'G';
if (kb >= 1024) return (kb / 1024).toFixed(0) + 'M';
return kb + 'K';
}
function fmtUptime(sec) {
if (sec == null || sec < 0) return '—';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = Math.floor(sec % 60);
if (d > 0) return d + 'd' + h + 'h';
if (h > 0) return h + 'h' + m + 'm';
if (m > 0) return m + 'm' + s + 's';
return s + 's';
}
function heatClass(pct) {
if (pct == null) return '';
if (pct >= 90) return 'hot';
return '';
}
function heatHtml(pct) {
if (pct == null) return '<span class="heat">—</span>';
return '<span class="heat ' + heatClass(pct) + '">' +
pct.toFixed(1) + '</span>';
}
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function (panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
}
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () { switchTab(btn.dataset.tab); });
});
/* ── Processes tab ──────────────────────────────────────────────── */
var gProcSortKey = 'cpu_percent';
var gProcSortDir = 'desc';
var gProcData = [];
var gExpandedRenderers = {}; // pid -> bool
function setProcSort(key) {
if (gProcSortKey === key) {
gProcSortDir = gProcSortDir === 'asc' ? 'desc' : 'asc';
} else {
gProcSortKey = key;
gProcSortDir = (key === 'name' || key === 'state' || key === 'ownership')
? 'asc' : 'desc';
}
renderProcTable();
}
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setProcSort(th.dataset.sort); });
});
function procSorter(a, b) {
var ka = a[gProcSortKey];
var kb = b[gProcSortKey];
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gProcSortDir === 'asc' ? cmp : -cmp;
}
function renderProcTable() {
var body = document.getElementById('proc-body');
body.innerHTML = '';
var sorted = gProcData.slice().sort(procSorter);
// Update sort indicators.
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gProcSortKey) {
th.classList.add(gProcSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (p) {
var tr = document.createElement('tr');
var isRenderer = p.ownership === 'webkit-renderer';
tr.className = isRenderer ? 'renderer-row' : '';
var tabsCount = (p.hosted_tabs || []).length;
var tabsCell;
if (isRenderer) {
tabsCell = String(tabsCount);
} else {
tabsCell = '—';
}
tr.innerHTML =
'<td>' + p.pid + '</td>' +
'<td class="title-cell">' + esc(p.name) + '</td>' +
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
'<td>' + fmtKb(p.rss_kb) + '</td>' +
'<td>' + fmtKb(p.pss_kb) + '</td>' +
'<td>' + (p.threads || 0) + '</td>' +
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
'<td>' + esc(p.state) + '</td>' +
'<td>' + esc(p.ownership) +
(p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '</td>' +
'<td>' + tabsCell + '</td>';
if (isRenderer) {
(function (pid, hosted) {
tr.addEventListener('click', function () {
gExpandedRenderers[pid] = !gExpandedRenderers[pid];
renderProcTable();
});
})(p.pid, p.hosted_tabs);
}
body.appendChild(tr);
// Expanded hosted-tabs row.
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
var sub = document.createElement('tr');
sub.className = 'hosted-tabs-row';
var html = '<td colspan="10">';
p.hosted_tabs.forEach(function (t) {
html += '<span class="ht-entry"><span class="ht-idx">#' +
t.index + '</span>' + esc(t.title || '(untitled)') +
' <span class="url-sub">' + esc(t.url) + '</span></span>';
});
html += '</td>';
sub.innerHTML = html;
body.appendChild(sub);
}
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
}
}
async function fetchProcesses() {
try {
var data = await sovereignGet('sovereign://processes/list?_t=' + Date.now());
gProcData = Array.isArray(data) ? data : [];
renderProcTable();
} catch (e) {
showMsg('Failed to load processes: ' + e.message, true);
}
}
/* ── Tabs tab ───────────────────────────────────────────────────── */
var gTabsSortKey = 'cpu_busy_percent';
var gTabsSortDir = 'desc';
var gTabsData = [];
var gSelectedTab = -1;
function setTabsSort(key) {
if (gTabsSortKey === key) {
gTabsSortDir = gTabsSortDir === 'asc' ? 'desc' : 'asc';
} else {
gTabsSortKey = key;
gTabsSortDir = (key === 'title') ? 'asc' : 'desc';
}
renderTabsTable();
}
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setTabsSort(th.dataset.sort); });
});
function tabSorter(a, b) {
var ka = a[gTabsSortKey];
var kb = b[gTabsSortKey];
// For probe fields, fall back to -1 if probe is null.
if (gTabsSortKey !== 'index' && gTabsSortKey !== 'title' &&
gTabsSortKey !== 'webprocess_pid') {
ka = (a.probe && a.probe[gTabsSortKey] != null) ? a.probe[gTabsSortKey] : -1;
kb = (b.probe && b.probe[gTabsSortKey] != null) ? b.probe[gTabsSortKey] : -1;
}
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gTabsSortDir === 'asc' ? cmp : -cmp;
}
function probeField(tab, key, fmt) {
if (!tab.probe) return '—';
var v = tab.probe[key];
if (v == null || v < 0) return '—';
return fmt ? fmt(v) : v;
}
function renderTabsTable() {
var body = document.getElementById('tabs-body');
body.innerHTML = '';
var sorted = gTabsData.slice().sort(tabSorter);
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gTabsSortKey) {
th.classList.add(gTabsSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (t) {
var tr = document.createElement('tr');
tr.className = 'tab-row' + (t.index === gSelectedTab ? ' selected' : '');
var busy = (t.probe && t.probe.cpu_busy_percent != null)
? t.probe.cpu_busy_percent : null;
var titleClass = (t.index === gSelectedTab) ? '' : 'dim';
tr.innerHTML =
'<td>' + t.index + '</td>' +
'<td class="title-cell"><span class="' + titleClass + '">' +
esc(t.title || '(untitled)') + '</span>' +
(t.is_internal ? ' (internal)' : '') +
'<br><span class="url-sub">' + esc(t.url) + '</span></td>' +
'<td>' + (t.is_internal ? '<span class="heat">—</span>'
: heatHtml(busy)) + '</td>' +
'<td>' + probeField(t, 'fps') + '</td>' +
'<td>' + probeField(t, 'timer_count') + '</td>' +
'<td>' + probeField(t, 'net_in_flight') + '</td>' +
'<td>' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '</td>' +
'<td>' + probeField(t, 'dom_node_count') + '</td>' +
'<td>' + (t.webprocess_pid || 0) + '</td>';
(function (idx) {
tr.addEventListener('click', function () { openDrilldown(idx); });
})(t.index);
body.appendChild(tr);
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="9" class="empty">No tabs.</td></tr>';
}
}
async function fetchTabs() {
try {
var data = await sovereignGet('sovereign://processes/tabs?_t=' + Date.now());
gTabsData = Array.isArray(data) ? data : [];
renderTabsTable();
// If a tab is selected, refresh its drill-down too.
if (gSelectedTab >= 0) refreshDrilldown();
} catch (e) {
showMsg('Failed to load tabs: ' + e.message, true);
}
}
/* ── Drill-down ─────────────────────────────────────────────────── */
async function openDrilldown(idx) {
gSelectedTab = idx;
renderTabsTable();
document.getElementById('drilldown').style.display = 'block';
await refreshDrilldown();
}
async function refreshDrilldown() {
if (gSelectedTab < 0) return;
try {
var d = await sovereignGet('sovereign://processes/tab_probe?index=' +
gSelectedTab);
renderDrilldown(d);
} catch (e) {
document.getElementById('dd-title').textContent = 'Tab #' + gSelectedTab;
document.getElementById('dd-summary').innerHTML =
'<p class="err-text">Failed to load drill-down: ' + esc(e.message) + '</p>';
}
}
function renderDrilldown(d) {
document.getElementById('dd-title').textContent =
'Tab #' + d.index + ' — ' + (d.title || '(untitled)');
var p = d.probe || {};
var rows = [
['URL', esc(d.url)],
['WebProcess PID', d.webprocess_pid || 0],
['CPU-busy %', p.cpu_busy_percent != null ? heatHtml(p.cpu_busy_percent) : '—'],
['FPS', p.fps != null ? p.fps : '—'],
['Active timers', p.timer_count != null ? p.timer_count : '—'],
['Net in-flight', p.net_in_flight != null ? p.net_in_flight : '—'],
['Net reqs (last sec)', p.net_requests_last_sec != null ? p.net_requests_last_sec : '—'],
['Heap used', p.heap_used_mb >= 0 ? p.heap_used_mb + 'M' : '—'],
['Event listeners', p.event_listener_count != null ? p.event_listener_count : '—'],
['Workers', p.worker_count != null ? p.worker_count : '—'],
['DOM nodes', p.dom_node_count >= 0 ? p.dom_node_count : '—']
];
var html = '';
rows.forEach(function (r) {
html += '<div class="info-row"><span>' + r[0] + '</span><span>' +
r[1] + '</span></div>';
});
document.getElementById('dd-summary').innerHTML = html;
// Long-task sources.
var ltBody = document.getElementById('dd-lt-sources-body');
ltBody.innerHTML = '';
(p.long_task_top_sources || []).forEach(function (s) {
ltBody.innerHTML += '<tr><td class="mono">' + esc(s.name) + '</td><td>' +
s.total_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) {
ltBody.innerHTML = '<tr><td colspan="3" class="empty">No long tasks.</td></tr>';
}
// Timers.
var tmBody = document.getElementById('dd-timers-body');
tmBody.innerHTML = '';
(p.timer_top || []).forEach(function (s) {
tmBody.innerHTML += '<tr><td class="mono">' + esc(s.code) + '</td><td>' +
s.interval_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.timer_top || p.timer_top.length === 0) {
tmBody.innerHTML = '<tr><td colspan="3" class="empty">No active timers.</td></tr>';
}
// Network endpoints.
var netBody = document.getElementById('dd-net-body');
netBody.innerHTML = '';
(p.net_top_endpoints || []).forEach(function (s) {
netBody.innerHTML += '<tr><td class="mono">' + esc(s.url) + '</td><td>' +
s.count + '</td></tr>';
});
if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) {
netBody.innerHTML = '<tr><td colspan="2" class="empty">No network requests.</td></tr>';
}
// Timeline.
var tl = document.getElementById('dd-timeline');
tl.innerHTML = '';
var timeline = p.long_task_timeline || [];
if (timeline.length === 0) {
tl.innerHTML = '<div class="tl-empty">No long tasks in the last 30s.</div>';
} else {
var maxDur = 1;
timeline.forEach(function (r) { if (r.duration > maxDur) maxDur = r.duration; });
timeline.forEach(function (r) {
var h = Math.max(2, Math.round((r.duration / maxDur) * 72));
var bar = document.createElement('span');
bar.className = 'tl-bar';
bar.style.height = h + 'px';
bar.title = r.duration + 'ms — ' + (r.name || 'unknown');
tl.appendChild(bar);
});
}
}
/* ── Drill-down actions ─────────────────────────────────────────── */
document.getElementById('dd-reload').addEventListener('click', function () {
if (gSelectedTab < 0) return;
// Trigger reload via the bridge — there's no dedicated endpoint, so
// we use the agent eval path indirectly. Simplest: navigate the tab
// by opening its URL again via sovereign://processes/tab_action.
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=reload').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-suspend').addEventListener('click', function () {
if (gSelectedTab < 0) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=suspend').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-close').addEventListener('click', function () {
if (gSelectedTab < 0) return;
if (!confirm('Close tab #' + gSelectedTab + '?')) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=close').then(function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
fetchTabs();
});
});
document.getElementById('dd-close-panel').addEventListener('click', function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
renderTabsTable();
});
/* ── Refresh button + polling ───────────────────────────────────── */
document.getElementById('refresh-btn').addEventListener('click', function () {
fetchProcesses();
fetchTabs();
});
function poll() {
fetchProcesses();
fetchTabs();
}
poll();
setInterval(poll, 1000);

View File

@@ -118,6 +118,13 @@
<button class="save-btn" onclick="save('search_engine')">Save</button></div>
</div>
<h2>Network</h2>
<div class="field">
<div><div class="setting-name">FIPS Mesh Network</div>
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
</div>
<h2>Security</h2>
<div class="setting">

View File

@@ -83,14 +83,17 @@ input, select, textarea {
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn {
.btn, .save-btn, button.btn {
background: var(--bg); color: var(--primary);
border: 1px solid var(--primary); border-radius: var(--radius);
-webkit-appearance: none !important; appearance: none !important;
border-width: 1px !important; border-style: solid !important;
border-color: var(--primary) !important; border-radius: var(--radius);
padding: 6px 16px; cursor: pointer; font-family: var(--font);
font-size: 13px; font-weight: bold; margin-left: 8px;
display: inline-block; text-decoration: none;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.btn:hover, .save-btn:hover { border-color: var(--accent); }
.btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }
.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); }