Compare commits

...
21 Commits
Author SHA1 Message Date
Laan Tungir da7b5eb72c v0.0.63 - Release: toggle menu bars keyboard shortcut (Ctrl+Shift+M, per-window) 2026-07-30 19:10:31 -04:00
Laan Tungir 2bc5ec7771 v0.0.62 - Release: remember last save directory in download file chooser 2026-07-29 10:41:13 -04:00
Laan Tungir 992b09357a v0.0.61 - Release: fix Save Image context menu — add download-started handler with file chooser dialog 2026-07-29 10:30:34 -04:00
Laan Tungir 24d07946b9 v0.0.60 - Release: fix increment_and_push.sh release flow (tar 'file changed' abort + set -e cleanup guard); rebuild release artifacts 2026-07-29 10:15:48 -04:00
Laan Tungir e0607fe045 v0.0.59 - Fix MCP segfault: drop redundant g_main_context_acquire/release in nested JS-eval/snapshot loops (was corrupting owner_count); allow ws:// WebSocket from https:// pages by registering ws as a secure scheme 2026-07-28 15:53:05 -04:00
Laan Tungir ae43decd4d v0.0.58 - Update vendored nostr_core_lib to v0.6.10 (derive_hmac, ttyUSB serial, qrexec transport) and bundle in-progress perf-probe/bookmarks/site-downloader changes 2026-07-28 07:15:53 -04:00
Laan Tungir 5e035bfe6b v0.0.57 - Added Download Website feature: right-click tab to crawl and save a site for offline viewing, with WebKit-based SPA link discovery and path-prefix filtering 2026-07-23 12:18:24 -04:00
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
69 changed files with 9114 additions and 916 deletions
+7
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
+7 -4
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
+1 -1
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/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 := 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 src/site_downloader.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)
+1 -1
View File
@@ -1 +1 @@
0.0.44
0.0.63
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

+144 -27
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
+62 -8
View File
@@ -27,6 +27,24 @@ 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() {
# Guard so an empty argument (e.g. a failed tarball path) does not cause
# this function to return non-zero and abort the script under `set -e`.
[[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true
}
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
@@ -234,8 +252,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 --------------------------------
@@ -264,17 +282,45 @@ build_release_binary() {
create_source_tarball() {
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
# Write the tarball to a temp path OUTSIDE the archived tree (.) so that
# creating it does not change the directory while tar is reading it
# (which makes tar emit "file changed as we read it" and exit 1, which
# under `set -e` would abort the whole release flow). Move it into place
# only after tar finishes.
local tmp_tarball
tmp_tarball=$(mktemp -t "${tarball_name}.XXXXXX") || return 1
# tar exits 1 for "file changed as we read it", which is benign here
# because the only thing changing is the tarball we are writing (now in
# /tmp). Accept exit codes 0 and 1 as success; anything else is fatal.
set +e
tar -czf "$tmp_tarball" \
--exclude="$BIN_NAME" \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
--exclude='nostr_core_lib' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
--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
local tar_rc=$?
set -e
if [[ $tar_rc -ne 0 && $tar_rc -ne 1 ]]; then
rm -f "$tmp_tarball"
print_error "tar failed with exit code $tar_rc"
return 1
fi
if ! mv "$tmp_tarball" "$tarball_name" 2>/dev/null; then
rm -f "$tmp_tarball"
print_error "Failed to move tarball into place: $tarball_name"
return 1
fi
echo "$tarball_name"
}
create_gitea_release() {
@@ -354,16 +400,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
+3 -2
View File
@@ -606,8 +606,9 @@ main() {
print_success "sovereign_browser installed."
cat >&2 <<EOF
${GREEN}[SUCCESS]${NC} Next steps:
Run: sovereign-browser start --login-method generate
Or: sovereign-browser start --login-method generate --url https://example.com
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
+261
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. |
+156
View File
@@ -0,0 +1,156 @@
# Plan: Download Website for Offline Viewing
## Goal
Add a "Download Website" option to the tab right-click context menu. It crawls
the current site (same-origin, bounded by a page cap), saves all pages and
assets to a per-profile directory, rewrites links to relative paths, and opens
the offline copy in a new tab via `file://`.
## User decisions
- **Depth:** Entire site — follow all same-origin links, no depth limit, bounded
by a max-page-count safety cap (default 500).
- **Storage:** Per-profile:
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/<hostname>/`
(falls back to `~/.sovereign_browser/offline_sites/<hostname>/` when not
logged in).
- **Feedback:** Small modal progress dialog with pages/files counts, a progress
bar, and a Cancel button.
## Trigger
Right-click on a tab → "Download Website" menu item. Added to **both** tab
context menus in [`src/tab_manager.c`](../src/tab_manager.c):
- `on_tab_label_button_press` menu (line ~828)
- `on_notebook_button_press` menu (line ~751)
The callback reads the right-clicked tab's `current_url` and is only active for
`http://` / `https://` URLs (greyed out / error dialog otherwise).
## Architecture
```mermaid
flowchart TD
A[Right-click tab] --> B[Download Website menu item]
B --> C{URL is http/https?}
C -- No --> D[Show error, abort]
C -- Yes --> E[Show progress dialog]
E --> F[Start worker thread BFS crawl]
F --> G[Fetch page via libsoup]
G --> H[Save HTML to offline_sites/host/path]
H --> I[Extract same-origin links + assets]
I --> J{Cancel clicked?}
J -- Yes --> K[Stop crawl]
J -- No --> L{More URLs and under cap?}
L -- Yes --> G
L -- No --> M[Close dialog]
K --> M
M --> N[Open file://offline_sites/host/index.html in new tab]
```
### New files
#### `src/site_downloader.h`
Public API (self-contained, called from the tab context menu):
```c
/* Start downloading the website at start_url. Shows a modal progress
* dialog parented to `parent`. On completion (or cancel) opens the
* offline copy in a new tab. No-op for non-http(s) URLs. */
void site_downloader_start(const char *start_url, GtkWindow *parent);
```
#### `src/site_downloader.c`
Implementation:
1. **Progress dialog**`GtkDialog` with:
- `GtkLabel` showing hostname + "Pages: N / Files: M"
- `GtkProgressBar` (pulse mode; or fraction = pages / cap)
- Cancel `GtkButton` → sets `g_atomic_int_set(&cancel, 1)`
2. **Worker thread**`g_thread_new("site-dl", ...)` running a BFS crawl:
- `SoupSession` (libsoup-3.0; pattern from
[`src/agent_llm.c`](../src/agent_llm.c:285) and
[`src/search.c`](../src/search.c:158)).
- Queue (GQueue) of URLs to fetch + a `GHashTable` visited-set.
- Politeness delay between requests (default 100ms).
3. **Fetch + save** — for each URL:
- `soup_session_send_and_read()` (sync; we're on a worker thread).
- Map URL → local path: mirror the URL path under the site root;
directory-style URLs → `index.html`; sanitize illegal filename chars.
- Write bytes to disk (`g_file_set_contents` for text, manual write for
binary).
- For HTML responses: extract links, rewrite them to relative paths, then
save the rewritten HTML.
4. **Link extraction & rewriting** (no HTML parser dep in the project, so use
regex over `href=`, `src=`, `srcset=`, `poster=`, and CSS `url(...)`):
- Resolve each link against the page URL (`g_uri_resolve_relative`).
- **Same-origin filter:** keep only links whose host matches the start
URL's host (via `g_uri_parse`, pattern from
[`src/tor_scheme.c`](../src/tor_scheme.c:74)). Enqueue same-origin HTML
pages; download all assets (CSS/JS/images/fonts/etc.) regardless of
origin but don't crawl cross-origin pages.
- Rewrite kept links to relative file paths so the offline copy works from
`file://`.
5. **Completion**`g_idle_add` a callback on the main thread that:
- Destroys the progress dialog.
- Opens `file://<offline_dir>/index.html` in a new tab via
`tab_manager_new_tab()`.
### Modified files
- [`src/tab_manager.c`](../src/tab_manager.c) — add "Download Website" item to
both context menus; new callback `on_tab_download_website` calling
`site_downloader_start(tab->current_url, g_window)`.
- [`Makefile`](../Makefile) — add `src/site_downloader.c` to `SRC`.
- [`src/settings.h`](../src/settings.h) / [`src/settings.c`](../src/settings.c)
— add to `browser_settings_t`:
- `offline_site_max_pages` (default 500)
- `offline_site_request_delay_ms` (default 100)
- load/save in `settings_load()` / `settings_save()`.
## Edge cases
- Non-http(s) URLs → menu item shows an error dialog, no crawl.
- Not logged in → fall back to global `~/.sovereign_browser/offline_sites/`.
- Redirects → follow libsoup redirects; use the final URL for same-origin
checks and path mapping.
- Binary assets → write raw bytes, never parse/rewrite.
- Duplicate URLs → visited-set prevents re-fetching.
- Very large sites → max-page cap stops the crawl; dialog shows "cap reached".
- Filename sanitization → replace illegal chars (`/`, `?`, `:`, etc.) with `_`.
- Cancel → worker checks the cancel flag between requests; partial downloads
are kept and the new tab opens to whatever was fetched.
## Implementation order
1. Add settings fields + load/save.
2. Create `site_downloader.h` / `site_downloader.c` skeleton: progress dialog
+ thread stub + completion callback.
3. Implement libsoup fetch + file write + URL→path mapping.
4. Implement HTML link extraction, same-origin filter, link rewriting.
5. Implement BFS crawl loop: visited-set, cap, cancel flag, progress updates.
6. Implement completion callback: open new tab with `file://` URL.
7. Wire "Download Website" into both tab context menus in `tab_manager.c`.
8. Add `site_downloader.c` to `Makefile`.
9. Build (`make`) and test with `tests/local-site/` served via
`python3 -m http.server`.
## Testing
- Serve `tests/local-site/` with `python3 -m http.server 8000` from the
`tests/` directory.
- Open `http://localhost:8000/local-site/index.html` in the browser.
- Right-click the tab → Download Website.
- Verify the progress dialog appears, files are saved under
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/localhost/`, and a new
tab opens the offline `index.html` with working relative links and assets.
- Test Cancel mid-download.
- Test a non-http URL (e.g. `sovereign://profile`) → error dialog.
+295
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).
+177
View File
@@ -0,0 +1,177 @@
# Plan: Keyboard Shortcut to Toggle All Menu Bars (per-window)
## Goal
Add a configurable keyboard shortcut that shows/hides all the browser's
"menu bars" — the per-tab toolbars, bookmark bars, and the tab strip —
**independently per window**. Pressing the shortcut in one window hides
that window's chrome only; other windows are unaffected. This mirrors how
the agent sidebar already works (per-window `sidebar_visible` on
[`window_state_t`](src/tab_manager.c:87)).
In sovereign_browser there is no traditional `GtkMenuBar`; each tab's chrome
is two widgets packed at the top of the tab page
([`tab->page`](src/tab_manager.c:2966)):
1. **`#main-toolbar`** — the horizontal GtkBox holding the hamburger menu
button, refresh/stop, back, forward, URL entry, and bookmark button
(built in [`tab_create()`](src/tab_manager.c:2973)).
2. **`#bookmark-bar`** — the bookmarks toolbar below the URL toolbar
([`tab->bookmark_bar`](src/tab_manager.c:3089)).
Plus the tab strip itself, which is the `GtkNotebook` header, controlled
via `gtk_notebook_set_show_tabs()`.
Hiding all three gives the webview the entire window (a focus / kiosk
mode). Pressing the shortcut again restores them.
## Design
### 1. New shortcut action
Add `SHORTCUT_TOGGLE_TOOLBARS` to the enum in [`shortcuts.h`](src/shortcuts.h:29)
(before `SHORTCUT_COUNT`) and a matching entry in the `g_registry` table in
[`shortcuts.c`](src/shortcuts.c:24):
| Field | Value |
|---------|----------------------------------------------------|
| id | `toggle_toolbars` |
| label | `Toggle menu bars` |
| desc | `Show or hide the tab strip, toolbars, and bookmark bars in the active window` |
| dflt | `<Control><Shift>m` |
Default `<Control><Shift>m` ("m" for menu) is free and mnemonic. It is a
browser-level shortcut (not consumed by WebKit for web content), so it
reaches the window-level `on_key_press` handler reliably.
Because the settings page enumerates actions via
[`sovereign://settings/config`](src/nostr_bridge.c:2994) (which loops over
`SHORTCUT_COUNT` calling [`shortcuts_meta()`](src/shortcuts.c:232) /
[`shortcuts_get()`](src/shortcuts.c:202)), the new action appears
automatically in the Keyboard Shortcuts section of
[`www/settings.html`](www/settings.html:74) with no JS changes. NIP-78 sync
also picks it up for free via [`shortcuts_serialize()`](src/shortcuts.c:247).
### 2. Track toolbar widgets on each tab
The `#main-toolbar` GtkBox is currently a local variable in
[`tab_create()`](src/tab_manager.c:2973) and is not stored on
[`tab_info_t`](src/tab_manager.h:23). Add a field:
```c
GtkWidget *toolbar; /* #main-toolbar (hamburger + nav + URL) */
```
to `tab_info_t` in [`tab_manager.h`](src/tab_manager.h:23), and assign
`tab->toolbar = toolbar;` in [`tab_create()`](src/tab_manager.c:2973).
`tab->bookmark_bar` already exists.
### 3. Per-window visibility flag
Add a field to [`window_state_t`](src/tab_manager.c:87) in
[`tab_manager.c`](src/tab_manager.c):
```c
gboolean toolbars_visible; /* per-window menu-bar visibility */
```
Initialize it to `TRUE` for `g_main_window` (in
[`tab_manager_init`](src/tab_manager.c:3598), near where
`sidebar_visible = FALSE` is set at line 3643) and for each aux window
created in [`tab_manager_new_window`](src/tab_manager.c:1262) (near line
1376 where `sidebar_visible = FALSE` is set).
### 4. Toggle function (active window only)
Add a public function in [`tab_manager.c`](src/tab_manager.c):
```c
void tab_manager_toggle_toolbars(void);
```
Implementation — mirrors [`tab_manager_toggle_sidebar()`](src/tab_manager.c:4143):
1. `window_state_t *ws = get_active_window_state();` — resolves the focused
window (main or aux), exactly like the sidebar toggle does.
2. Flip `ws->toolbars_visible`.
3. `gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), ws->toolbars_visible);`
— hides/shows the tab strip for this window's notebook only.
4. Iterate `g_tabs[0..g_tab_count)` and, for each tab whose **page belongs
to this window's notebook** (use [`tab_find_notebook(tab->page)`](src/tab_manager.c:2660)
and compare to `ws->notebook`), call
`gtk_widget_set_visible(tab->toolbar, ws->toolbars_visible)` and
`gtk_widget_set_visible(tab->bookmark_bar, ws->toolbars_visible)`.
This affects only the active window's tabs and tab strip; other windows
keep their own `toolbars_visible` state.
### 5. Respect hidden state for new tabs
In [`tab_create()`](src/tab_manager.c:2973), after building `toolbar` and
`tab->bookmark_bar`, look up the notebook the tab is being added to (the
target notebook — `g_active_notebook` / `get_effective_notebook()` at tab
creation time, or `tab_find_notebook(tab->page)` after packing) and its
`window_state_t` via [`window_state_for_notebook()`](src/tab_manager.c:4051),
then apply that window's flag:
```c
window_state_t *ws = window_state_for_notebook(<target notebook>);
gboolean vis = ws ? ws->toolbars_visible : TRUE;
gtk_widget_set_visible(toolbar, vis);
gtk_widget_set_visible(tab->bookmark_bar, vis);
```
So a tab opened in a hidden-chrome window is also hidden, while a tab
opened in a visible-chrome window stays visible. The tab strip visibility
is per-notebook via `gtk_notebook_set_show_tabs()`, so no extra work is
needed there.
### 6. Dispatch the shortcut
Add a case to the switch in [`on_key_press`](src/main.c:611) in
[`main.c`](src/main.c):
```c
case SHORTCUT_TOGGLE_TOOLBARS:
tab_manager_toggle_toolbars();
return TRUE;
```
The key-press handler is window-level, so `get_active_window_state()`
inside the toggle resolves to the window that had focus when the key was
pressed — giving the per-window behavior.
### 7. Expose in tab_manager.h
Add the prototype for `tab_manager_toggle_toolbars(void)` near the other
toggle prototypes ([`tab_manager_toggle_inspector`](src/tab_manager.h),
[`tab_manager_toggle_sidebar`](src/tab_manager.h)).
## Files touched
| File | Change |
|---------------------|---------------------------------------------------------------|
| [`src/shortcuts.h`](src/shortcuts.h:29) | Add `SHORTCUT_TOGGLE_TOOLBARS` to enum |
| [`src/shortcuts.c`](src/shortcuts.c:24) | Add registry entry (id/label/desc/dflt) |
| [`src/tab_manager.h`](src/tab_manager.h:23) | Add `GtkWidget *toolbar` field + `tab_manager_toggle_toolbars` prototype |
| [`src/tab_manager.c`](src/tab_manager.c:87) | Add `toolbars_visible` to `window_state_t`; init to TRUE for main + aux windows |
| [`src/tab_manager.c`](src/tab_manager.c:2973) | Store `tab->toolbar`; apply window's `toolbars_visible` on creation; implement `tab_manager_toggle_toolbars()` (active window only: tab strip + that window's tabs' toolbars/bookmark bars) |
| [`src/main.c`](src/main.c:611) | Add `case SHORTCUT_TOGGLE_TOOLBARS` dispatch |
No changes needed in `www/` (settings page auto-discovers the action) or in
`settings_sync.c` (NIP-78 sync already serializes all actions).
## Verification
1. `make` — builds clean.
2. `./browser.sh restart --login-method generate --url https://example.com`
3. Press `Ctrl+Shift+M` → in the focused window, toolbars + bookmark bar +
tab strip disappear; the webview fills the entire window. Press again →
they reappear.
4. Open a second window (`Ctrl+N`). Hide chrome in window A with
`Ctrl+Shift+M`; window B's chrome stays visible. Hide B independently.
5. With chrome hidden in a window, `Ctrl+T` opens a new tab in that window
whose toolbars are also hidden; the tab strip stays hidden too.
6. Open `sovereign://settings` → Keyboard Shortcuts section lists
"Toggle menu bars" with `Ctrl+Shift+M`; rebind it and confirm the new
binding works.
+224
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+78 -8
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. */
+15 -2
View File
@@ -230,8 +230,8 @@ const mcp_tool_def_t tool_defs[] = {
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"click_at",
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation. Optional 'button': 1=left (default), 2=middle, 3=right (opens the native context menu).",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"button\":{\"type\":\"integer\",\"enum\":[1,2,3],\"default\":1}},\"required\":[\"x\",\"y\"]}"},
{"fill",
"Clear an input and fill it with a value.",
@@ -574,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]));
+59 -1
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");
}
+32 -12
View File
@@ -12,10 +12,31 @@
#include <stdlib.h>
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
* on the default context to wait for the result. The key is to acquire
* the context before creating the loop so that the WebKit IPC sources
* are properly dispatched.
* WebKitGTK's evaluate_javascript is async. We run a nested GMainLoop
* on the default main context until the JS callback (or a timeout)
* quits it.
*
* This function is routinely called from within a Soup server source
* callback (the MCP HTTP handler) that is already dispatching on the
* default main context. Running a nested GMainLoop on the same context
* is the standard GTK modal pattern (gtk_dialog_run does the same) and
* is safe: g_main_loop_run() manages context ownership internally for
* the nested run, and the outer loop's dispatch is suspended until the
* inner loop quits.
*
* The PREVIOUS code additionally called g_main_context_acquire()/
* g_main_context_release() around the loop. That was the bug: the
* default context is already owned by the outer dispatch, so acquire()
* fails silently (owner_count is NOT incremented), yet release() then
* decrements it unconditionally — producing
* "g_main_context_release_unlocked: assertion 'context->owner_count > 0'"
* and, combined with the recursive check, a segfault. The fix is simply
* to drop the explicit acquire/release and let g_main_loop_run() handle
* ownership. We must NOT use a g_main_context_iteration() pump instead,
* because that would re-dispatch unrelated ready sources (other Soup
* requests, WebKit load events, GTK UI events) re-entrantly while a
* tool call is in progress, which can deadlock when an MCP tool runs
* during page loading.
*/
typedef struct {
@@ -48,6 +69,7 @@ static gboolean on_js_timeout(gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
if (!ctx->done) {
g_printerr("[agent] JS evaluation timed out\n");
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
@@ -61,26 +83,24 @@ char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
js_eval_ctx_t ctx = {0};
/* Use the default main context for the nested loop. */
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
/* Nested loop on the default context. Do NOT acquire/release the
* context here — see the block comment above. */
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
/* Add a timeout source to the same context. */
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
/* Start async evaluation. */
/* Start async evaluation. The completion callback fires on the
* default main context (where WebKit schedules its async work). */
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_js_finished, &ctx);
/* Run the nested loop until JS completes or timeout. */
/* Run the nested loop until JS completes or the timeout quits it. */
g_main_loop_run(ctx.loop);
/* Cleanup. */
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
g_main_context_release(main_ctx);
return ctx.result;
}
+68 -22
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>
@@ -129,9 +130,12 @@ static WebKitWebView *get_active_webview(void) {
*
* Coordinates from getBoundingClientRect() are relative to the viewport
* and match the webview's GdkWindow coordinate space when the webview
* fills its parent window. Returns TRUE on success. */
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
* fills its parent window. `button` is 1 (left), 2 (middle), or 3 (right).
* Returns TRUE on success. */
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y,
int button) {
if (wv == NULL) return FALSE;
if (button < 1) button = 1;
GtkWidget *widget = GTK_WIDGET(wv);
GdkWindow *window = gtk_widget_get_window(widget);
@@ -149,7 +153,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
press->button.y = y;
press->button.axes = NULL;
press->button.state = 0; /* no modifiers */
press->button.button = 1; /* left button */
press->button.button = button;
press->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
gdk_event_put(press);
gdk_event_free(press);
@@ -169,7 +173,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
release->button.y = y;
release->button.axes = NULL;
release->button.state = 0;
release->button.button = 1;
release->button.button = button;
release->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
gdk_event_put(release);
gdk_event_free(release);
@@ -1001,8 +1005,16 @@ static cJSON *tool_eval(cJSON *params) {
/* ── Screenshot tool ──────────────────────────────────────────────── */
/* Context for the async webkit_web_view_snapshot() call. We use a
* nested GMainLoop to turn the async API into a synchronous one,
* matching the pattern in agent_js_eval_sync(). */
* async API into a synchronous one.
*
* We run a nested GMainLoop on the default main context (the standard
* GTK modal pattern) but do NOT call g_main_context_acquire()/
* g_main_context_release() around it — the default context is already
* owned by the outer dispatch (these tools are invoked from the MCP
* HTTP handler), so an explicit acquire fails silently and a matching
* release corrupts the owner_count, causing a segfault. The nested
* g_main_loop_run() handles ownership internally. See the block comment
* in agent_js_eval_sync() for the full rationale. */
typedef struct {
cairo_surface_t *surface;
gboolean done;
@@ -1030,6 +1042,7 @@ static gboolean snapshot_timeout_cb(gpointer user_data) {
snapshot_ctx_t *ctx = (snapshot_ctx_t *)user_data;
if (!ctx->done) {
g_printerr("[agent] screenshot timed out\n");
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
@@ -1050,11 +1063,10 @@ static cJSON *tool_screenshot(cJSON *params) {
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
/* Set up the nested GMainLoop to wait for the async snapshot. */
/* Nested GMainLoop on the default context. No acquire/release —
* see the note on snapshot_ctx_t above. */
snapshot_ctx_t ctx = {0};
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
@@ -1070,7 +1082,6 @@ static cJSON *tool_screenshot(cJSON *params) {
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
g_main_context_release(main_ctx);
if (!ctx.done || ctx.surface == NULL) {
if (ctx.surface) cairo_surface_destroy(ctx.surface);
@@ -1165,7 +1176,7 @@ static cJSON *tool_click(cJSON *params) {
double cy = jy->valuedouble + jh->valuedouble / 2.0;
cJSON_Delete(box);
g_free(sel);
if (synthesize_click_at(wv, cx, cy)) {
if (synthesize_click_at(wv, cx, cy, 1)) {
return make_success(NULL);
}
/* Fall through to JS fallback if GDK synthesis failed. */
@@ -1209,7 +1220,17 @@ static cJSON *tool_click(cJSON *params) {
/* click_at — Click at explicit viewport coordinates via GDK event
* synthesis. Useful when the agent already knows the target point
* (e.g. from a screenshot or get_box result) and wants to bypass
* selector resolution entirely. */
* selector resolution entirely. Optional 'button': 1=left (default),
* 2=middle, 3=right (triggers the native context menu). */
static int click_button_from_params(cJSON *params) {
cJSON *jb = cJSON_GetObjectItem(params, "button");
if (jb && cJSON_IsNumber(jb)) {
int b = jb->valueint;
if (b >= 1 && b <= 3) return b;
}
return 1;
}
static cJSON *tool_click_at(cJSON *params) {
cJSON *jx = cJSON_GetObjectItem(params, "x");
cJSON *jy = cJSON_GetObjectItem(params, "y");
@@ -1222,8 +1243,9 @@ static cJSON *tool_click_at(cJSON *params) {
double x = jx->valuedouble;
double y = jy->valuedouble;
int button = click_button_from_params(params);
if (!synthesize_click_at(wv, x, y)) {
if (!synthesize_click_at(wv, x, y, button)) {
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
}
return make_success(NULL);
@@ -4021,11 +4043,9 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
/* We don't strictly need the result, but wait for it to complete. */
g_free(ann_result);
/* Step 3: Take the WebKit snapshot (same pattern as tool_screenshot). */
/* Step 3: Take the WebKit snapshot (same nested-loop pattern as tool_screenshot). */
snapshot_ctx_t ctx = {0};
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
@@ -4038,7 +4058,6 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
g_main_context_release(main_ctx);
if (!ctx.done || ctx.surface == NULL) {
if (ctx.surface) cairo_surface_destroy(ctx.surface);
@@ -4275,6 +4294,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 ||
@@ -4556,7 +4599,7 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
double cy = jy->valuedouble + jh->valuedouble / 2.0;
cJSON_Delete(box);
g_free(box_result);
if (synthesize_click_at(wv, cx, cy)) {
if (synthesize_click_at(wv, cx, cy, 1)) {
g_free(sel);
/* Send a success response directly through the
* WebSocket connection, matching the async path. */
@@ -4594,7 +4637,9 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
/* click_at — coordinate-based click via GDK event synthesis (sync).
* Takes explicit x/y viewport coordinates and dispatches a real
* GDK button press/release. Bypasses selector resolution entirely. */
* GDK button press/release. Optional 'button': 1=left (default),
* 2=middle, 3=right (triggers the native context menu). Bypasses
* selector resolution entirely. */
if (conn != NULL && strcmp(tool_name, "click_at") == 0) {
cJSON *jx = cJSON_GetObjectItem(params, "x");
cJSON *jy = cJSON_GetObjectItem(params, "y");
@@ -4605,7 +4650,8 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
if (wv == NULL) return make_error("NO_TAB", "No active tab");
double x = jx->valuedouble;
double y = jy->valuedouble;
if (!synthesize_click_at(wv, x, y)) {
int button = click_button_from_params(params);
if (!synthesize_click_at(wv, x, y, button)) {
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
}
/* Send success response directly through the WebSocket. */
+819 -300
View File
File diff suppressed because it is too large Load Diff
+91 -42
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
+8
View File
@@ -109,6 +109,7 @@ enum {
OPT_VERBOSE,
OPT_QUIET,
OPT_HELP,
OPT_DOWNLOAD_URL,
};
static struct option long_opts[] = {
@@ -143,6 +144,7 @@ static struct option long_opts[] = {
{"verbose", no_argument, 0, OPT_VERBOSE},
{"quiet", no_argument, 0, OPT_QUIET},
{"help", no_argument, 0, OPT_HELP},
{"download-url", required_argument, 0, OPT_DOWNLOAD_URL},
{0, 0, 0, 0}
};
@@ -198,6 +200,7 @@ void cli_args_free(cli_args_t *args) {
g_free(args->nsigner_transport);
g_free(args->nsigner_device);
g_free(args->nsigner_service);
g_free(args->download_url);
memset(args, 0, sizeof(*args));
}
@@ -356,6 +359,11 @@ int cli_parse(int *argc, char ***argv, cli_args_t *out) {
out->want_help = TRUE;
return 0;
case OPT_DOWNLOAD_URL:
g_free(out->download_url);
out->download_url = xstrdup(optarg);
break;
default:
fprintf(stderr, "[cli] unhandled option\n");
return -1;
+4
View File
@@ -68,6 +68,10 @@ typedef struct {
int verbose; /* --verbose / -v (repeatable count) */
gboolean quiet; /* --quiet / -q */
/* Offline site download (test/diagnostic hook — triggers
* site_downloader_start shortly after startup). */
char *download_url; /* --download-url <url> */
/* Exit-request flags set by --help / --version. */
gboolean want_help;
gboolean want_version;
+3
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;
+37
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) {
+18
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
+49 -4
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);
+281 -77
View File
@@ -48,6 +48,7 @@
#include "agent_server.h"
#include "agent_login.h"
#include "cli.h"
#include "site_downloader.h"
#include "db.h"
#include "profile.h"
#include "relay_fetch.h"
@@ -55,6 +56,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 --------------------------------------------------- *
@@ -66,21 +69,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;
@@ -89,6 +104,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. */
@@ -104,6 +125,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) {
@@ -112,6 +150,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;
@@ -125,12 +164,80 @@ const char *app_get_pubkey_hex(void) { return g_state.pubkey_hex; }
key_store_method_t app_get_method(void) { return g_state.method; }
gboolean app_get_readonly(void) { return g_state.readonly; }
/* Idle callback for the --download-url diagnostic flag: triggers the
* offline site downloader on the main thread shortly after startup. */
static gboolean dl_test_trigger(gpointer data) {
const char *url = (const char *)data;
GtkWindow *win = gtk_window_get_focus(GTK_WINDOW(g_window)) ?
g_window : NULL;
/* g_window may not be set yet in some early-trigger scenarios; fall
* back to NULL (unparented dialog). */
site_downloader_start(url, win);
return G_SOURCE_REMOVE;
}
/* ---- Menu proxy functions ------------------------------------------- *
* These wrap the app_state_t-aware callbacks so they match GTK signal
* handler signatures and can be called from tab_manager.c's hamburger
* 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);
@@ -138,6 +245,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);
}
@@ -157,6 +268,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);
}
@@ -176,15 +302,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) {
@@ -272,60 +434,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_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");
}
tab_manager_new_tab("sovereign://agents");
}
/* The hamburger-menu "FIPS Mesh…" item navigates the active tab to
* the sovereign://fips internal page, which renders the FIPS mesh
* network status + management UI. */
/* 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");
}
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://fips");
} else {
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 --------------------------------------------- *
@@ -453,6 +600,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;
@@ -476,6 +627,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
tab_manager_toggle_sidebar();
return TRUE;
case SHORTCUT_TOGGLE_TOOLBARS:
tab_manager_toggle_toolbars();
return TRUE;
case SHORTCUT_ZOOM_IN:
tab_manager_zoom_in();
return TRUE;
@@ -649,6 +804,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;
@@ -677,9 +836,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");
@@ -734,6 +893,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) {
@@ -968,50 +1173,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, "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 *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);
tor_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);
@@ -1019,6 +1209,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:
@@ -1056,6 +1251,15 @@ int main(int argc, char **argv) {
}
}
/* Diagnostic/test hook: --download-url triggers the offline site
* downloader shortly after startup (same code path as the tab
* context menu's "Download Website"). */
if (cli.download_url != NULL && cli.download_url[0] != '\0') {
char *dl_url = g_strdup(cli.download_url);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
(GSourceFunc)dl_test_trigger, dl_url, g_free);
}
cli_args_free(&cli);
gtk_widget_show_all(window);
+9 -1
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,8 +67,15 @@ static int ensure_private_dir(const char *path, char *error, size_t error_size)
void net_services_refresh_proxy(void) {
if (!g_initialized) return;
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 (dm == NULL) return;
webkit_website_data_manager_set_network_proxy_settings(
dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL);
}
+339 -173
View File
@@ -34,6 +34,7 @@
#include "net_services.h"
#include "fips_control.h"
#include "relay_fetch.h"
#include "process_info.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
@@ -472,12 +473,15 @@ static const char *sovereign_page_css(void) {
" min-width: 200px; }\n"
" input:focus, select:focus, textarea:focus {\n"
" border-color: var(--accent); outline: none; }\n"
" .btn, .save-btn { background: var(--bg); color: var(--primary);\n"
" border: 1px solid var(--primary); border-radius: var(--radius);\n"
" .btn, .save-btn, button.btn { background: var(--bg); color: var(--primary);\n"
" -webkit-appearance: none !important; appearance: none !important;\n"
" border-width: 1px !important; border-style: solid !important;\n"
" border-color: var(--primary) !important; border-radius: var(--radius);\n"
" padding: 6px 16px; cursor: pointer; font-family: var(--font);\n"
" font-size: 13px; font-weight: bold; margin-left: 8px;\n"
" display: inline-block; text-decoration: none;\n"
" transition: border-color 0.2s, background 0.2s, color 0.2s; }\n"
" .btn:hover, .save-btn:hover { border-color: var(--accent); }\n"
" .btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }\n"
" .btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }\n"
" .btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" .btn-del { border-color: var(--accent); color: var(--accent); }\n"
@@ -1091,6 +1095,7 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
new_state = bs->theme_dark;
settings_save();
settings_sync_publish();
tab_manager_apply_settings(); /* re-apply GTK app theme */
reload = 0; /* Don't reload — the JS handles the visual toggle */
} else if (strcmp(feature, "agent_server_enabled") == 0) {
bs->agent_server_enabled = !bs->agent_server_enabled;
@@ -1508,140 +1513,12 @@ static void handle_profile_page(WebKitURISchemeRequest *request) {
/* ── Bookmarks page (sovereign://bookmarks) ──────────────────────── */
/* Generate the sovereign://bookmarks HTML page.
* Shows all bookmark directories and their bookmarks, with add/delete/move
* controls. In read-only / no-login mode, add/delete are disabled. */
static void handle_bookmarks_page(WebKitURISchemeRequest *request) {
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly);
char *head = sovereign_page_head("Bookmarks");
GString *html = g_string_new(head);
g_free(head);
g_string_append(html,
"<h1>Bookmarks</h1>\n");
if (!have_signer) {
g_string_append(html,
"<p class='note'>Read-only mode — sign in to add, edit, or delete "
"bookmarks. Showing cached bookmarks from the local database.</p>\n");
}
/* Add bookmark form (only if signer available). */
if (have_signer) {
g_string_append(html,
"<h2>Add Bookmark</h2>\n"
"<div class='form'>\n"
" <input type='text' id='bm-url' placeholder='URL' size='40'>\n"
" <input type='text' id='bm-title' placeholder='Title' size='25'>\n"
" <select id='bm-dir'>\n");
for (int i = 0; i < dir_count; i++) {
char *esc = g_markup_escape_text(dirs[i].name, -1);
g_string_append_printf(html, " <option value='%s'>%s</option>\n",
esc, esc);
g_free(esc);
}
g_string_append(html,
" </select>\n"
" <button class='btn' onclick='addBookmark()'>Add</button>\n"
"</div>\n"
"<div class='form'>\n"
" <input type='text' id='new-dir' placeholder='New directory name' size='25'>\n"
" <button class='btn' onclick='createDir()'>Create Directory</button>\n"
"</div>\n");
}
/* List directories and bookmarks. */
g_string_append(html, "<h2>Bookmarks</h2>\n");
if (dir_count == 0) {
g_string_append(html,
"<p class='empty'>No bookmarks yet. Click the bookmark button "
"in the toolbar to save a page.</p>\n");
} else {
for (int i = 0; i < dir_count; i++) {
const bookmark_dir_t *dir = &dirs[i];
char *dir_esc = g_markup_escape_text(dir->name, -1);
g_string_append_printf(html,
"<div class='dir-header'><h3>%s (%d)</h3>",
dir_esc, dir->count);
if (have_signer && strcmp(dir->name, "General") != 0) {
g_string_append_printf(html,
" <a class='btn btn-del' href='sovereign://bookmarks/deletedir?dir=%s' "
"onclick=\"return confirm('Delete directory \\\"%s\\\"? "
"Bookmarks will be moved to General.')\">Delete Dir</a>",
g_uri_escape_string(dir->name, NULL, FALSE),
dir_esc);
}
g_string_append(html, "</div>\n");
if (dir->count == 0) {
g_string_append(html, "<p class='empty'>(empty)</p>\n");
} else {
for (int j = 0; j < dir->count; j++) {
const bookmark_t *bm = &dir->bookmarks[j];
char *url_esc = g_uri_escape_string(bm->url, NULL, FALSE);
char *title_esc = g_markup_escape_text(bm->title, -1);
char *url_display = g_markup_escape_text(bm->url, -1);
char *dir_for_link = g_uri_escape_string(dir->name, NULL, FALSE);
g_string_append_printf(html,
"<div class='bm'>"
"<div><div class='bm-title'>%s</div>"
"<div><a class='bm-url' href='%s'>%s</a></div>"
"<div class='bm-meta'>Added: %ld</div></div>"
"<div class='actions'>"
"<a class='btn' href='%s'>Open</a>",
title_esc[0] ? title_esc : "(untitled)",
url_display, url_display, bm->added,
url_display);
if (have_signer) {
g_string_append_printf(html,
" <a class='btn btn-del' href='sovereign://bookmarks/delete?dir=%s&url=%s' "
"onclick=\"return confirm('Delete this bookmark?')\">Delete</a>",
dir_for_link, url_esc);
}
g_string_append(html, "</div></div>\n");
g_free(url_esc);
g_free(title_esc);
g_free(url_display);
g_free(dir_for_link);
}
}
g_free(dir_esc);
}
}
g_string_append(html,
"\n<script>\n"
" function addBookmark() {\n"
" var url = encodeURIComponent(document.getElementById('bm-url').value);\n"
" var title = encodeURIComponent(document.getElementById('bm-title').value);\n"
" var dir = encodeURIComponent(document.getElementById('bm-dir').value);\n"
" if (!url) { alert('URL is required'); return; }\n"
" location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url + '&title=' + title;\n"
" }\n"
" function createDir() {\n"
" var name = encodeURIComponent(document.getElementById('new-dir').value);\n"
" if (!name) { alert('Directory name is required'); return; }\n"
" location.href = 'sovereign://bookmarks/createdir?name=' + name;\n"
" }\n"
"</script>\n");
char *foot = sovereign_page_foot();
g_string_append(html, foot);
g_free(foot);
char *html_str = g_string_free(html, FALSE);
respond_html(request, html_str);
g_free(html_str);
}
/* The sovereign://bookmarks HTML page is now served from the embedded file
* www/bookmarks.html (see the route dispatch in handle_uri_scheme_request).
* The legacy inline HTML generator was removed when the bookmarks data model
* changed from flat directories to a nested tree. The tree is rendered
* client-side by www/bookmarks.js, which fetches sovereign://bookmarks/list
* (JSON) — see handle_bookmarks_list_json above. */
/* Handle sovereign://bookmarks/add?dir=...&url=...&title=... */
static void handle_bookmarks_add(WebKitURISchemeRequest *request,
@@ -1696,6 +1573,34 @@ static void handle_bookmarks_delete(WebKitURISchemeRequest *request,
g_free(dir); g_free(url);
}
/* Handle sovereign://bookmarks/rename?dir=...&url=...&title=...
* Renames a bookmark's user-editable title in place. */
static void handle_bookmarks_rename(WebKitURISchemeRequest *request,
const char *query) {
char *dir = query_param(query, "dir");
char *url = query_param(query, "url");
char *title = query_param(query, "title");
if (!url || url[0] == '\0') {
respond_error_json(request, -1, "Missing url parameter");
g_free(dir); g_free(url); g_free(title);
return;
}
int rc = bookmarks_rename(dir ? dir : "General", url, title ? title : "");
if (rc != 0) {
respond_error_json(request, -1, "Failed to rename bookmark");
} else {
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "status", "renamed");
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
g_free(dir); g_free(url); g_free(title);
}
/* Handle sovereign://bookmarks/createdir?name=... */
static void handle_bookmarks_createdir(WebKitURISchemeRequest *request,
const char *query) {
@@ -1746,6 +1651,64 @@ static void handle_bookmarks_deletedir(WebKitURISchemeRequest *request,
g_free(dir);
}
/* Handle sovereign://bookmarks/move?from=...&to=...&url=...
* Moves a bookmark from one folder path to another. */
static void handle_bookmarks_move(WebKitURISchemeRequest *request,
const char *query) {
char *from = query_param(query, "from");
char *to = query_param(query, "to");
char *url = query_param(query, "url");
if (!url || url[0] == '\0') {
respond_error_json(request, -1, "Missing url parameter");
g_free(from); g_free(to); g_free(url);
return;
}
int rc = bookmarks_move(from ? from : "General",
url,
to ? to : "General");
if (rc != 0) {
respond_error_json(request, -1, "Failed to move bookmark");
} else {
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "status", "moved");
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
g_free(from); g_free(to); g_free(url);
}
/* Handle sovereign://bookmarks/renamedir?old=...&new=...
* Renames a folder (and its entire subtree) to a new path. */
static void handle_bookmarks_renamedir(WebKitURISchemeRequest *request,
const char *query) {
char *old_path = query_param(query, "old");
char *new_path = query_param(query, "new");
if (!old_path || old_path[0] == '\0' ||
!new_path || new_path[0] == '\0') {
respond_error_json(request, -1, "Missing old or new parameter");
g_free(old_path); g_free(new_path);
return;
}
int rc = bookmarks_rename_dir(old_path, new_path);
if (rc != 0) {
respond_error_json(request, -1, "Failed to rename directory");
} else {
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "status", "renamed");
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
g_free(old_path); g_free(new_path);
}
/* ── Agent pages (sovereign://agents) ───────────────────────────── */
/* Generate the sovereign://agents provider config HTML page.
@@ -1883,7 +1846,7 @@ static void handle_agents_page(WebKitURISchemeRequest *request) {
" <button class='save-btn' onclick=\"save('agent.llm_system_prompt')\">Save</button></div>\n"
"</div>\n"
"\n"
"<p><a href='sovereign://agents/chat'>Open Agent Chat</a></p>\n"
"<p><a href='sovereign://agents/chat' target='_blank'>Open Agent Chat</a></p>\n"
"\n"
"<div id='status' class='status'></div>\n"
"\n"
@@ -3046,6 +3009,138 @@ static void handle_settings_config_json(WebKitURISchemeRequest *request) {
free(json);
}
/* ── Processes / performance page (sovereign://processes) ─────────── */
/* Handle sovereign://processes/list — return the Layer 1 process list
* as a JSON array. Polled by www/processes.js every 1s. */
static void handle_processes_list_json(WebKitURISchemeRequest *request) {
cJSON *arr = process_info_get_processes_json();
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/tabs — return the Layer 2 tab list
* (tab identity + latest probe) as a JSON array. */
static void handle_processes_tabs_json(WebKitURISchemeRequest *request) {
cJSON *arr = process_info_get_tabs_json();
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/tab_probe?index=N — return the drill-down
* JSON for a single tab (full probe with timeline + top sources). */
static void handle_processes_tab_probe_json(WebKitURISchemeRequest *request,
const char *query) {
int idx = -1;
if (query) {
const char *p = strstr(query, "index=");
if (p) idx = atoi(p + 6);
}
if (idx < 0) {
respond_error_json(request, -1, "Missing or invalid index");
return;
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
respond_error_json(request, -1, "Tab index out of range");
return;
}
char *json = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
respond_json(request, json);
free(json);
}
/* Handle sovereign://processes/probe-report?tab_index=N&body=<json>
* Records the probe report from a page's perf-probe.js. The body is
* URL-encoded JSON passed as a query parameter (same convention as the
* window.nostr shim — WebKitGTK's custom scheme handler doesn't expose
* POST bodies cleanly). Returns 204 No Content (empty JSON). */
static void handle_processes_probe_report(WebKitURISchemeRequest *request,
const char *query) {
int tab_index = -1;
const char *body_encoded = NULL;
if (query) {
/* Parse "tab_index=N&body=<encoded>" or "body=<encoded>&tab_index=N". */
const char *p = strstr(query, "tab_index=");
if (p) tab_index = atoi(p + 10);
p = strstr(query, "body=");
if (p) body_encoded = p + 5;
}
if (tab_index < 0 || body_encoded == NULL) {
respond_json(request, "{\"status\":\"ignored\"}");
return;
}
/* URL-decode the body. */
char *body = g_uri_unescape_string(body_encoded, NULL);
if (body == NULL) {
respond_json(request, "{\"status\":\"decode_failed\"}");
return;
}
cJSON *probe = cJSON_Parse(body);
g_free(body);
if (probe == NULL) {
respond_json(request, "{\"status\":\"bad_json\"}");
return;
}
process_info_record_probe(tab_index, probe); /* takes ownership */
respond_json(request, "{\"status\":\"ok\"}");
}
/* Handle sovereign://processes/tab_action?index=N&action=reload|suspend|close
* Performs a tab lifecycle action from the drill-down panel. */
static void handle_processes_tab_action(WebKitURISchemeRequest *request,
const char *query) {
int idx = -1;
char action[16] = "";
if (query) {
const char *p = strstr(query, "index=");
if (p) idx = atoi(p + 6);
p = strstr(query, "action=");
if (p) {
const char *v = p + 7;
size_t i = 0;
while (v[i] && v[i] != '&' && i < sizeof(action) - 1) {
action[i] = v[i]; i++;
}
action[i] = '\0';
}
}
if (idx < 0 || idx >= tab_manager_count()) {
respond_error_json(request, -1, "Invalid tab index");
return;
}
if (strcmp(action, "reload") == 0) {
tab_info_t *tab = tab_manager_get(idx);
if (tab && tab->webview) webkit_web_view_reload(tab->webview);
respond_json(request, "{\"status\":\"reloaded\"}");
} else if (strcmp(action, "suspend") == 0) {
/* Suspend = navigate to about:blank, preserving the URL in
* current_url so the user can see what was suspended. The
* tab_manager load handler will overwrite current_url, so we
* restore it after the load. */
tab_info_t *tab = tab_manager_get(idx);
if (tab && tab->webview) {
char saved[TAB_URL_MAX];
strncpy(saved, tab->current_url, sizeof(saved) - 1);
saved[sizeof(saved) - 1] = '\0';
webkit_web_view_load_uri(tab->webview, "about:blank");
strncpy(tab->current_url, saved, sizeof(tab->current_url) - 1);
tab->current_url[sizeof(tab->current_url) - 1] = '\0';
}
respond_json(request, "{\"status\":\"suspended\"}");
} else if (strcmp(action, "close") == 0) {
tab_manager_close_tab(idx);
respond_json(request, "{\"status\":\"closed\"}");
} else {
respond_error_json(request, -1, "Unknown action");
}
}
/* ── FIPS mesh management page (sovereign://fips) ─────────────────── */
/* Map a service_state_t to a lowercase string for the JSON status. */
@@ -3710,38 +3805,53 @@ static void handle_fips_directory_json(WebKitURISchemeRequest *request) {
free(json);
}
/* Handle sovereign://bookmarks/list — return all bookmark directories
* and their bookmarks as JSON. Used by www/bookmarks.js. */
/* Recursively serialize a bookmark_node_t into a cJSON object. */
static cJSON *bookmark_node_to_json(const bookmark_node_t *node) {
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "name", node->name ? node->name : "");
cJSON_AddStringToObject(obj, "path", node->path ? node->path : "");
cJSON_AddNumberToObject(obj, "count", node->bookmark_count);
cJSON *bms = cJSON_CreateArray();
for (int j = 0; j < node->bookmark_count; j++) {
const bookmark_t *bm = &node->bookmarks[j];
cJSON *bobj = cJSON_CreateObject();
cJSON_AddStringToObject(bobj, "url", bm->url);
cJSON_AddStringToObject(bobj, "title", bm->title);
cJSON_AddNumberToObject(bobj, "added", (double)bm->added);
cJSON_AddItemToArray(bms, bobj);
}
cJSON_AddItemToObject(obj, "bookmarks", bms);
cJSON *children = cJSON_CreateArray();
for (int i = 0; i < node->child_count; i++) {
cJSON_AddItemToArray(children,
bookmark_node_to_json(&node->children[i]));
}
cJSON_AddItemToObject(obj, "children", children);
return obj;
}
/* Handle sovereign://bookmarks/list — return the bookmark tree as JSON.
* Used by www/bookmarks.js. Shape:
* {"have_signer": bool, "tree": {name,path,bookmarks[],children[]}} */
static void handle_bookmarks_list_json(WebKitURISchemeRequest *request) {
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly);
cJSON *root = cJSON_CreateObject();
cJSON_AddBoolToObject(root, "have_signer", have_signer);
const bookmark_node_t *root = bookmarks_get_root();
cJSON *dirs_arr = cJSON_CreateArray();
for (int i = 0; i < dir_count; i++) {
const bookmark_dir_t *dir = &dirs[i];
cJSON *dobj = cJSON_CreateObject();
cJSON_AddStringToObject(dobj, "name", dir->name);
cJSON_AddNumberToObject(dobj, "count", dir->count);
cJSON *bms = cJSON_CreateArray();
for (int j = 0; j < dir->count; j++) {
const bookmark_t *bm = &dir->bookmarks[j];
cJSON *bobj = cJSON_CreateObject();
cJSON_AddStringToObject(bobj, "url", bm->url);
cJSON_AddStringToObject(bobj, "title", bm->title);
cJSON_AddNumberToObject(bobj, "added", (double)bm->added);
cJSON_AddItemToArray(bms, bobj);
}
cJSON_AddItemToObject(dobj, "bookmarks", bms);
cJSON_AddItemToArray(dirs_arr, dobj);
cJSON *root_obj = cJSON_CreateObject();
cJSON_AddBoolToObject(root_obj, "have_signer", have_signer);
if (root) {
cJSON *tree = bookmark_node_to_json(root);
cJSON_AddItemToObject(root_obj, "tree", tree);
} else {
cJSON_AddNullToObject(root_obj, "tree");
}
cJSON_AddItemToObject(root, "dirs", dirs_arr);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
char *json = cJSON_PrintUnformatted(root_obj);
cJSON_Delete(root_obj);
respond_json(request, json);
free(json);
}
@@ -3905,7 +4015,11 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
* nostr bridge shim calls to avoid terminal spam. */
if (strncmp(uri, "sovereign://agents/status", 25) != 0 &&
strncmp(uri, "sovereign://agents/messages", 27) != 0 &&
strncmp(uri, "sovereign://nostr/", 18) != 0) {
strncmp(uri, "sovereign://nostr/", 18) != 0 &&
strncmp(uri, "sovereign://processes/list", 25) != 0 &&
strncmp(uri, "sovereign://processes/tabs", 25) != 0 &&
strncmp(uri, "sovereign://processes/probe-report", 33) != 0 &&
strncmp(uri, "sovereign://processes/tab_probe", 30) != 0) {
g_print("[bridge] %s\n", uri);
}
@@ -3996,20 +4110,32 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
handle_bookmarks_list_json(request);
return;
}
if (strncmp(uri, "sovereign://bookmarks/add?", 25) == 0) {
handle_bookmarks_add(request, uri + 25);
if (strncmp(uri, "sovereign://bookmarks/add?", 26) == 0) {
handle_bookmarks_add(request, uri + 26);
return;
}
if (strncmp(uri, "sovereign://bookmarks/delete?", 28) == 0) {
handle_bookmarks_delete(request, uri + 28);
if (strncmp(uri, "sovereign://bookmarks/delete?", 29) == 0) {
handle_bookmarks_delete(request, uri + 29);
return;
}
if (strncmp(uri, "sovereign://bookmarks/createdir?", 31) == 0) {
handle_bookmarks_createdir(request, uri + 31);
if (strncmp(uri, "sovereign://bookmarks/createdir?", 32) == 0) {
handle_bookmarks_createdir(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://bookmarks/deletedir?", 31) == 0) {
handle_bookmarks_deletedir(request, uri + 31);
if (strncmp(uri, "sovereign://bookmarks/deletedir?", 32) == 0) {
handle_bookmarks_deletedir(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://bookmarks/move?", 27) == 0) {
handle_bookmarks_move(request, uri + 27);
return;
}
if (strncmp(uri, "sovereign://bookmarks/renamedir?", 32) == 0) {
handle_bookmarks_renamedir(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://bookmarks/rename?", 29) == 0) {
handle_bookmarks_rename(request, uri + 29);
return;
}
@@ -4269,6 +4395,46 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
return;
}
/* Route sovereign://processes — process + per-tab performance page.
* Served from embedded www/processes.{html,css,js}. */
if (strcmp(uri, "sovereign://processes") == 0 ||
strncmp(uri, "sovereign://processes?", 22) == 0) {
serve_embedded_file(request, "processes.html");
return;
}
if (strcmp(uri, "sovereign://processes.css") == 0 ||
strncmp(uri, "sovereign://processes.css?", 26) == 0) {
serve_embedded_file(request, "processes.css");
return;
}
if (strcmp(uri, "sovereign://processes.js") == 0 ||
strncmp(uri, "sovereign://processes.js?", 25) == 0) {
serve_embedded_file(request, "processes.js");
return;
}
if (strcmp(uri, "sovereign://processes/list") == 0 ||
strncmp(uri, "sovereign://processes/list?", 26) == 0) {
handle_processes_list_json(request);
return;
}
if (strcmp(uri, "sovereign://processes/tabs") == 0 ||
strncmp(uri, "sovereign://processes/tabs?", 26) == 0) {
handle_processes_tabs_json(request);
return;
}
if (strncmp(uri, "sovereign://processes/tab_probe?", 32) == 0) {
handle_processes_tab_probe_json(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://processes/probe-report?", 35) == 0) {
handle_processes_probe_report(request, uri + 35);
return;
}
if (strncmp(uri, "sovereign://processes/tab_action?", 33) == 0) {
handle_processes_tab_action(request, uri + 33);
return;
}
/* Route sovereign://fips — FIPS mesh management page. */
if (strcmp(uri, "sovereign://fips") == 0 ||
strncmp(uri, "sovereign://fips?", 17) == 0) {
+43 -1
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
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
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
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
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 */
+40
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';
+15
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).
+29
View File
@@ -126,6 +126,14 @@ static void settings_set_defaults(browser_settings_t *s) {
s->fips_control_socket[0] = '\0';
snprintf(s->fips_config_dir, sizeof(s->fips_config_dir),
"~/.sovereign_browser/fips");
s->offline_site_max_pages = SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT;
s->offline_site_request_delay_ms = SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT;
/* Perf probe is OFF by default — its instrumentation overhead
* (patched addEventListener/setTimeout/setInterval/XHR + a permanent
* rAF loop) breaks scrolling on listener-heavy pages. */
s->perf_probe_enabled = FALSE;
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
@@ -317,6 +325,20 @@ void settings_load_user(void) {
val = db_kv_get("fips.config_dir");
if (val) snprintf(g_settings.fips_config_dir, sizeof(g_settings.fips_config_dir), "%s", val);
val = db_kv_get("offline_site.max_pages");
if (val) {
g_settings.offline_site_max_pages = parse_int(val, g_settings.offline_site_max_pages);
if (g_settings.offline_site_max_pages < 1) g_settings.offline_site_max_pages = 1;
}
val = db_kv_get("offline_site.request_delay_ms");
if (val) {
g_settings.offline_site_request_delay_ms = parse_int(val, g_settings.offline_site_request_delay_ms);
if (g_settings.offline_site_request_delay_ms < 0) g_settings.offline_site_request_delay_ms = 0;
}
val = db_kv_get("perf_probe.enabled");
if (val) g_settings.perf_probe_enabled = parse_bool(val, g_settings.perf_probe_enabled);
/* Agent LLM provider settings — these are the "resolved" values
* (active provider's base_url + api_key + selected model). They are
* populated from db_kv here for local persistence, and overwritten
@@ -483,6 +505,13 @@ void settings_save_user(void) {
db_kv_set("fips.control_socket", g_settings.fips_control_socket);
db_kv_set("fips.config_dir", g_settings.fips_config_dir);
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_max_pages);
db_kv_set("offline_site.max_pages", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_request_delay_ms);
db_kv_set("offline_site.request_delay_ms", buf);
db_kv_set("perf_probe.enabled", g_settings.perf_probe_enabled ? "true" : "false");
/* Agent LLM provider settings — resolved values (active provider). */
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
+17
View File
@@ -34,6 +34,8 @@ extern "C" {
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
#define SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT 500
#define SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT 100
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
@@ -121,6 +123,21 @@ typedef struct {
char fips_binary_path[256];
char fips_control_socket[512];
char fips_config_dir[512];
/* Offline website downloader (right-click tab → Download Website). */
int offline_site_max_pages; /* safety cap on pages crawled */
int offline_site_request_delay_ms; /* politeness delay between requests */
/* Per-tab performance probe (sovereign://processes). The probe patches
* addEventListener / setTimeout / setInterval / XHR and runs a permanent
* requestAnimationFrame loop on every page, which adds main-thread
* overhead. On listener-heavy pages (e.g. NIP-17 DM clients that
* re-register many listeners per re-render) this overhead is enough to
* starve the main thread and break scrolling — the page's own
* scroll-to-bottom rAF wins the race and yanks the view to the bottom.
* Default OFF so normal browsing is not penalized; enable explicitly when
* you want the processes/perf view. */
gboolean perf_probe_enabled;
} browser_settings_t;
/*
+10
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",
@@ -95,6 +100,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"toggle_sidebar", "Toggle agent sidebar",
"Show or hide the agent chat sidebar", "<Control><Shift>a"
},
[SHORTCUT_TOGGLE_TOOLBARS] = {
"toggle_toolbars", "Toggle menu bars",
"Show or hide the tab strip, toolbars, and bookmark bars in the active window",
"<Control><Shift>m"
},
[SHORTCUT_ZOOM_IN] = {
"zoom_in", "Zoom in",
"Increase the zoom level of the active tab", "<Control>plus"
+2
View File
@@ -41,10 +41,12 @@ typedef enum {
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_OPEN_PROCESSES,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
SHORTCUT_TOGGLE_SIDEBAR,
SHORTCUT_TOGGLE_TOOLBARS,
SHORTCUT_ZOOM_IN,
SHORTCUT_ZOOM_OUT,
SHORTCUT_ZOOM_RESET,
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
/*
* site_downloader.h — download a website for offline viewing
*
* Triggered from the tab right-click context menu ("Download Website").
* Crawls the current site (same-origin, bounded by a page-count cap),
* saves all pages and assets to a per-profile directory, rewrites links
* to relative paths, and opens the offline copy in a new tab via file://.
*
* See plans/download-website-offline.md for the full design.
*/
#ifndef SITE_DOWNLOADER_H
#define SITE_DOWNLOADER_H
#include <gtk/gtk.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Start downloading the website at start_url.
*
* Shows a modal progress dialog (pages/files counts + progress bar +
* Cancel button) parented to `parent`. A background thread runs a BFS
* crawl over same-origin links using libsoup, saving pages and assets
* under ~/.sovereign_browser/profiles/<pubkey>/offline_sites/<host>/
* (or the global ~/.sovereign_browser/offline_sites/<host>/ when not
* logged in). On completion or cancel, the progress dialog is closed
* and the offline index.html is opened in a new tab.
*
* No-op (shows an error dialog) for non-http(s) URLs.
*
* start_url — absolute http:// or https:// URL of the page to download
* parent — the GtkWindow to parent the progress dialog to (may be NULL)
*/
void site_downloader_start(const char *start_url, GtkWindow *parent);
#ifdef __cplusplus
}
#endif
#endif /* SITE_DOWNLOADER_H */
+836 -159
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -29,6 +29,8 @@ typedef struct {
GtkWidget *title_label; /* child of tab_label */
GtkWidget *favicon; /* favicon image in tab_label */
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
GtkWidget *toolbar; /* #main-toolbar (hamburger + nav + URL) */
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];
@@ -46,6 +48,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
@@ -141,6 +155,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).
*/
@@ -196,6 +229,15 @@ void tab_manager_zoom_reset(void);
*/
void tab_manager_toggle_sidebar(void);
/*
* Toggle visibility of all "menu bars" in the active window: the tab strip
* (GtkNotebook header), each tab's #main-toolbar, and each tab's bookmark
* bar. Per-window only the focused window is affected, mirroring
* tab_manager_toggle_sidebar(). New tabs opened in a hidden-chrome window
* inherit the hidden state.
*/
void tab_manager_toggle_toolbars(void);
/*
* Returns the main webview (the web page) of the active tab, never the
* sidebar webview. This is used by agent tools so they always operate
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.44"
#define SB_VERSION "v0.0.63"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 44
#define SB_VERSION_PATCH 63
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+339
View File
@@ -0,0 +1,339 @@
/*
* 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 <gtk/gtk.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;
/* ── Download handling ─────────────────────────────────────────────── *
* WebKitGTK fires the "download-started" signal on the WebKitWebContext
* whenever the user triggers a download most notably the "Save Image"
* context-menu item on an <img>. In the webkit2gtk-4.1 API the download
* does NOT auto-prompt for a destination: the application must handle the
* download's "decide-destination" signal (which supplies a suggested
* filename), call webkit_download_set_destination(), and return TRUE.
* If nobody handles "decide-destination", WebKit writes the file to
* G_USER_DIRECTORY_DOWNLOAD using the suggested name but on many
* minimal/containerized setups that directory resolves to a path the
* user never sees, so "Save Image" appears to do nothing.
*
* Our flow:
* download-started (context) take a ref, wire finished/failed
* decide-destination (download) show a GtkFileChooserDialog pre-filled
* with the suggested filename, set the
* chosen destination, return TRUE
* finished / failed unref the download
*
* Since 2.40, returning TRUE from "decide-destination" without
* immediately calling webkit_download_set_destination() is allowed: the
* download pauses until the destination is set or the download is
* cancelled. We use that to run a modal file chooser inside the handler.
*/
/* Find a reasonable parent GtkWindow for the file chooser. We prefer the
* currently-active (focused) top-level window; if none is focused we fall
* back to the first visible top-level. web_context.c deliberately does
* not depend on tab_manager.c's g_active_window, so this stays self-
* contained. Returns NULL if no usable window is found. */
static GtkWindow *pick_dialog_parent(void) {
GList *toplevels = gtk_window_list_toplevels();
GtkWindow *active = NULL;
GtkWindow *first_visible = NULL;
for (GList *l = toplevels; l != NULL; l = l->next) {
GtkWindow *w = GTK_WINDOW(l->data);
if (!gtk_widget_get_visible(GTK_WIDGET(w))) continue;
if (first_visible == NULL) first_visible = w;
if (gtk_window_is_active(w)) { active = w; break; }
}
GtkWindow *result = active ? active : first_visible;
if (result != NULL) g_object_ref_sink(G_OBJECT(result));
g_list_free(toplevels);
return result; /* caller unrefs */
}
/* Reduce a possibly-full suggested path to its final path segment so the
* save dialog never starts in an unexpected directory. Returns a newly
* allocated string. */
static char *basename_of(const char *name) {
if (name == NULL || name[0] == '\0') return NULL;
const char *slash = strrchr(name, '/');
return g_strdup(slash ? slash + 1 : name);
}
static void on_download_finished(WebKitDownload *dl, gpointer user) {
(void)user;
const char *dest = webkit_download_get_destination(dl);
g_print("[download] Finished: %s\n", dest ? dest : "(no destination)");
g_object_unref(dl);
}
static void on_download_failed(WebKitDownload *dl, GError *err, gpointer user) {
(void)user;
g_printerr("[download] Failed: %s\n", err ? err->message : "(unknown)");
g_object_unref(dl);
}
/* "decide-destination" handler. WebKit passes the suggested filename
* (derived from the response Content-Disposition / URL). We show a save
* dialog, set the destination, and return TRUE. Returning TRUE without
* setting a destination is explicitly supported since 2.40 and pauses
* the download until we set one (or cancel). */
/* Remember the directory the user last saved a download to, so the next
* save dialog opens in the same folder instead of the default. Owned by
* this module (g_free'd on teardown). NULL until the first successful
* save. */
static char *g_last_save_dir = NULL;
static gboolean on_download_decide_destination(WebKitDownload *dl,
const char *suggested,
gpointer user) {
(void)user;
char *default_name = basename_of(suggested);
if (default_name == NULL || default_name[0] == '\0') {
g_free(default_name);
default_name = g_strdup("download");
}
GtkWindow *parent = pick_dialog_parent();
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Save File", parent, GTK_FILE_CHOOSER_ACTION_SAVE,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Save", GTK_RESPONSE_ACCEPT,
NULL);
if (parent) g_object_unref(parent);
GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog);
/* If the user saved a download before, open the dialog in that same
* directory. Otherwise GTK falls back to the current working
* directory / last-used folder for the application. */
if (g_last_save_dir != NULL && g_last_save_dir[0] != '\0') {
gtk_file_chooser_set_current_folder(chooser, g_last_save_dir);
}
gtk_file_chooser_set_current_name(chooser, default_name);
gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
gint resp = gtk_dialog_run(GTK_DIALOG(dialog));
char *path = (resp == GTK_RESPONSE_ACCEPT)
? gtk_file_chooser_get_filename(chooser)
: NULL;
gtk_widget_destroy(dialog);
g_free(default_name);
if (path == NULL) {
/* User cancelled — abort the download. */
webkit_download_cancel(dl);
return TRUE; /* we handled it (by cancelling) */
}
/* Remember the directory the user chose for next time. */
char *dir = g_path_get_dirname(path);
if (dir != NULL && dir[0] != '\0' &&
(g_last_save_dir == NULL ||
g_strcmp0(dir, g_last_save_dir) != 0)) {
g_free(g_last_save_dir);
g_last_save_dir = g_strdup(dir);
}
g_free(dir);
char *uri = g_filename_to_uri(path, NULL, NULL);
g_free(path);
if (uri == NULL) {
webkit_download_cancel(dl);
return TRUE;
}
webkit_download_set_destination(dl, uri);
g_free(uri);
return TRUE; /* stop other handlers; destination is now set */
}
static void on_download_started(WebKitWebContext *ctx,
WebKitDownload *dl,
gpointer user_data) {
(void)ctx;
(void)user_data;
/* Hold a ref for the lifetime of the download so our finished/failed
* handlers can safely access it; we unref there. */
g_object_ref(dl);
g_signal_connect(dl, "decide-destination",
G_CALLBACK(on_download_decide_destination), NULL);
g_signal_connect(dl, "finished",
G_CALLBACK(on_download_finished), NULL);
g_signal_connect(dl, "failed",
G_CALLBACK(on_download_failed), 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.
*
* Also register ws (insecure WebSocket) as secure. Without this,
* WebKitGTK blocks ws:// connections opened from https:// pages as
* mixed active content (a secure page may not connect to an
* insecure origin). This browser is intentionally open and should
* be able to connect to anywhere, including local plaintext
* WebSocket services, so we treat ws:// as a secure scheme — the
* same status wss:// already has by default. */
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_secure(sec_mgr, "ws");
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);
/* Handle downloads — most importantly the "Save Image" context-menu
* action. Without this handler WebKitGTK cancels the download (and
* the user sees nothing happen). See on_download_started() above. */
g_signal_connect(ctx, "download-started",
G_CALLBACK(on_download_started), NULL);
}
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
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
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
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 */
+27
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>
+111
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
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;
}
+134
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
+1 -1
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>
+87 -2
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; }
+4 -4
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>
+243 -58
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;
}
+369
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
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
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
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);
+1 -1
View File
@@ -122,7 +122,7 @@
<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">Open FIPS Page</a></div>
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
</div>
<h2>Security</h2>
+6 -3
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); }