Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473e2e4a96 | ||
|
|
9a55fe15f7 | ||
|
|
5e26c502ee | ||
|
|
45aa46f6c4 | ||
|
|
480d758241 | ||
|
|
eb093ee16a | ||
|
|
618d52ff69 | ||
|
|
3d32c64b0a | ||
|
|
ca2907b357 | ||
|
|
0892a9c7e1 | ||
|
|
b3955e4231 | ||
|
|
3bacac4a42 | ||
|
|
fe1112f480 | ||
|
|
fa5dd93c6d | ||
|
|
7d91a186d4 | ||
|
|
6a8e51c812 | ||
|
|
12f8b45015 | ||
|
|
b90ea0bb11 | ||
|
|
5b949ec034 | ||
|
|
43ee97bc38 | ||
|
|
3ac4d4f9dc | ||
|
|
b111fffeaa |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
11
.roorules
11
.roorules
@@ -6,12 +6,15 @@
|
||||
|
||||
```bash
|
||||
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
|
||||
./browser.sh stop # kill running instance
|
||||
./browser.sh stop # kill ALL running instances
|
||||
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
|
||||
./browser.sh status # check if running + MCP responsive
|
||||
./browser.sh log # tail last 50 lines of browser log
|
||||
./browser.sh status # list all running instances + MCP status
|
||||
./browser.sh log # tail the browser log
|
||||
./browser.sh mcp-url # print MCP endpoint URL(s) for running instance(s)
|
||||
```
|
||||
|
||||
**Multiple instances:** if the default MCP port (17777) is already in use by another instance, the browser automatically falls back to an OS-assigned port and records it in `/tmp/sovereign_browser_<pid>.port`. `browser.sh` reads these discovery files to find/stop/status every instance. Use `./browser.sh mcp-url` to discover the actual endpoint(s).
|
||||
|
||||
## Login
|
||||
|
||||
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
|
||||
@@ -36,7 +39,7 @@ Alternatively, you can still log in at runtime via the MCP `login` tool with `{"
|
||||
|
||||
## MCP Server
|
||||
|
||||
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
|
||||
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP) for the first instance; subsequent instances get an OS-assigned port — use `./browser.sh mcp-url` to discover the actual endpoint(s)
|
||||
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
|
||||
- See `.roo/agents.md` for the full tool list and workflow
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
# 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)
|
||||
|
||||
BIN
artifacts/mcp-after-restart.png
Normal file
BIN
artifacts/mcp-after-restart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-bookmark-toolbar-check.png
Normal file
BIN
artifacts/mcp-bookmark-toolbar-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-browser-full.png
Normal file
BIN
artifacts/mcp-browser-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
artifacts/mcp-header-white-check.png
Normal file
BIN
artifacts/mcp-header-white-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
171
browser.sh
171
browser.sh
@@ -8,10 +8,16 @@
|
||||
#
|
||||
# Usage:
|
||||
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
|
||||
# ./browser.sh stop — kill any running instance
|
||||
# ./browser.sh stop — kill ALL running instances
|
||||
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
|
||||
# ./browser.sh status — check if running + MCP responsive
|
||||
# ./browser.sh status — list all running instances + MCP status
|
||||
# ./browser.sh log — tail the browser log
|
||||
# ./browser.sh mcp-url — print MCP endpoint URL(s) for running instance(s)
|
||||
#
|
||||
# Multiple instances: if the default MCP port (17777) is already in use by
|
||||
# another instance, the browser automatically falls back to an OS-assigned
|
||||
# port and records it in /tmp/sovereign_browser_<pid>.port. browser.sh
|
||||
# reads these discovery files to find/stop/status every instance.
|
||||
#
|
||||
# Extra args after start/restart are forwarded to the binary, e.g.:
|
||||
# ./browser.sh start --login-method generate --url https://example.com
|
||||
@@ -25,7 +31,37 @@ set -euo pipefail
|
||||
BIN="./sovereign_browser"
|
||||
LOG="/tmp/sovereign_browser.log"
|
||||
PID_FILE="/tmp/sovereign_browser.pid"
|
||||
PORT=17777
|
||||
DEFAULT_PORT=17777
|
||||
DISCOVERY_GLOB="/tmp/sovereign_browser_*.port"
|
||||
|
||||
# Read the port for a given PID from its discovery file. Falls back to
|
||||
# DEFAULT_PORT (17777) if no discovery file exists (e.g. older binary, or
|
||||
# the first instance that successfully bound the default port and wrote a
|
||||
# discovery file with 17777). Echoes the port to stdout.
|
||||
port_for_pid() {
|
||||
local pid="${1:-}"
|
||||
local file="/tmp/sovereign_browser_${pid}.port"
|
||||
if [[ -f "$file" ]]; then
|
||||
local p
|
||||
p=$(awk '{print $2}' "$file" 2>/dev/null | head -n1)
|
||||
if [[ -n "$p" ]]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "$DEFAULT_PORT"
|
||||
}
|
||||
|
||||
# Echo the port for the currently-running instance (per get_pid), or
|
||||
# DEFAULT_PORT if not running.
|
||||
current_port() {
|
||||
local pid
|
||||
if pid=$(get_pid 2>/dev/null); then
|
||||
port_for_pid "$pid"
|
||||
else
|
||||
echo "$DEFAULT_PORT"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a PID is actually the sovereign_browser process.
|
||||
# This safety guard prevents us from accidentally killing unrelated
|
||||
@@ -53,13 +89,24 @@ get_pid() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback: find the process LISTENing on the port.
|
||||
# Fallback 1: scan discovery files for a live sovereign_browser PID.
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
|
||||
echo "$dpid"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback 2: find the process LISTENing on the default port.
|
||||
# -sTCP:LISTEN matches only the listening socket, never client
|
||||
# connections (e.g. Roo Code's MCP client). This is critical —
|
||||
# without it, `lsof -ti :PORT` would also return Roo Code's PID
|
||||
# and we'd kill the agent driving the browser.
|
||||
local port_pid
|
||||
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
|
||||
port_pid=$(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
|
||||
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
|
||||
echo "$port_pid"
|
||||
return 0
|
||||
@@ -67,13 +114,36 @@ get_pid() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Echo all live sovereign_browser PIDs (one per line), discovered via the
|
||||
# discovery files and the default-port listen socket. Used by cmd_stop to
|
||||
# kill every running instance.
|
||||
all_pids() {
|
||||
local pids=()
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
|
||||
pids+=("$dpid")
|
||||
fi
|
||||
done
|
||||
local pp
|
||||
for pp in $(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null || true); do
|
||||
if is_browser_pid "$pp"; then
|
||||
pids+=("$pp")
|
||||
fi
|
||||
done
|
||||
# Deduplicate.
|
||||
printf '%s\n' "${pids[@]}" 2>/dev/null | sort -u | grep -v '^$' || true
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local stopped_any=0
|
||||
|
||||
# Collect candidate PIDs from the PID file and from LISTENing sockets.
|
||||
# We use -sTCP:LISTEN so we only match the server, never client
|
||||
# connections (Roo Code / VS Code ext host connect to the MCP server
|
||||
# and would be killed by a bare `lsof -ti :PORT`).
|
||||
# Collect ALL running sovereign_browser PIDs — from the PID file,
|
||||
# discovery files, and the default-port listen socket. This kills
|
||||
# every instance when multiple are running on different ports.
|
||||
local candidates=()
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
while IFS= read -r pid; do
|
||||
@@ -82,7 +152,7 @@ cmd_stop() {
|
||||
fi
|
||||
while IFS= read -r pid; do
|
||||
[[ -n "$pid" ]] && candidates+=("$pid")
|
||||
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
done < <(all_pids)
|
||||
|
||||
# Deduplicate and kill only verified sovereign_browser PIDs.
|
||||
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
|
||||
@@ -96,7 +166,7 @@ cmd_stop() {
|
||||
# Wait for graceful shutdown, then force-kill survivors (verified only).
|
||||
if [[ "$stopped_any" -eq 1 ]]; then
|
||||
sleep 1
|
||||
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
|
||||
for pid in $(all_pids); do
|
||||
if is_browser_pid "$pid"; then
|
||||
echo "[browser] Force killing PID $pid..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
@@ -106,10 +176,21 @@ cmd_stop() {
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
# Clean up any leftover discovery files (the browser removes its own on
|
||||
# clean shutdown, but force-killed instances leave them behind).
|
||||
local f
|
||||
for f in $DISCOVERY_GLOB; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local dpid
|
||||
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
|
||||
if [[ -z "$dpid" ]] || ! kill -0 "$dpid" 2>/dev/null; then
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# Final check: is anything still LISTENing on the port?
|
||||
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "[browser] Warning: process still listening on port $PORT."
|
||||
# Final check: is anything still running?
|
||||
if [[ -n "$(all_pids)" ]]; then
|
||||
echo "[browser] Warning: some instances still running."
|
||||
else
|
||||
echo "[browser] Stopped."
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
# --- Cleanup trap -------------------------------------------------------
|
||||
# Any temp files registered here are removed on exit, including when the
|
||||
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
|
||||
# leftover sovereign_browser-*.tar.gz files from accumulating in the
|
||||
# project root when a release run is interrupted.
|
||||
CLEANUP_FILES=()
|
||||
cleanup_on_exit() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
|
||||
done
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
|
||||
# Gitea repo for release uploads (derived from the origin remote).
|
||||
@@ -234,8 +250,8 @@ git_commit_and_push() {
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
git push origin "$CURRENT_BRANCH"
|
||||
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
|
||||
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
|
||||
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
|
||||
}
|
||||
|
||||
# --- Release mode: build + Gitea release --------------------------------
|
||||
@@ -270,6 +286,10 @@ create_source_tarball() {
|
||||
--exclude='*.log' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude='nostr_core_lib' \
|
||||
--exclude='tests/local-site/assets/*.mp4' \
|
||||
--exclude='tests/local-site/assets/*.m4a' \
|
||||
--exclude='tests/local-site/assets/*.webm' \
|
||||
--exclude='tests/local-site/assets/*.ogg' \
|
||||
. > /dev/null 2>&1; then
|
||||
echo "$tarball_name"
|
||||
else
|
||||
@@ -354,16 +374,24 @@ if [[ "$RELEASE_MODE" == true ]]; then
|
||||
|
||||
local_tarball=""
|
||||
local_tarball=$(create_source_tarball || true)
|
||||
# Register the tarball for cleanup so it's removed on exit even if a
|
||||
# later step fails or the script is interrupted. The trap also catches
|
||||
# the case where create_gitea_release aborts via `set -e`.
|
||||
register_cleanup "$local_tarball"
|
||||
|
||||
print_status "Creating Gitea release for $NEW_VERSION..."
|
||||
release_id=""
|
||||
release_id=$(create_gitea_release || true)
|
||||
|
||||
if [[ -n "$release_id" ]]; then
|
||||
print_status "Uploading release assets (binary + tarball)..."
|
||||
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
|
||||
else
|
||||
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
|
||||
fi
|
||||
|
||||
# Clean up the tarball (the binary is gitignored already).
|
||||
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
|
||||
# The tarball is removed by the cleanup_on_exit trap registered above,
|
||||
# so no manual rm is needed here. The binary is gitignored already.
|
||||
|
||||
print_success "Release flow completed: $NEW_VERSION"
|
||||
else
|
||||
|
||||
251
install.sh
251
install.sh
@@ -5,7 +5,8 @@ set -euo pipefail
|
||||
#
|
||||
# Installs:
|
||||
# * apt runtime deps (WebKitGTK 4.1, libsoup-3, tor, build toolchain)
|
||||
# * FIPS (built from source from https://github.com/jmcorgan/fips) with CAP_NET_ADMIN
|
||||
# * FIPS prebuilt binary from GitHub releases (source-build fallback via
|
||||
# --build-fips-from-source) with CAP_NET_ADMIN
|
||||
# * sovereign_browser prebuilt binary from Gitea releases (source-build fallback)
|
||||
# * a `sovereign-browser` wrapper that detaches the GUI
|
||||
#
|
||||
@@ -20,18 +21,22 @@ set -euo pipefail
|
||||
|
||||
GITEA_API="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
|
||||
GITEA_DOWNLOAD_BASE="https://git.laantungir.net/laantungir/sovereign_browser/releases/download"
|
||||
FIPS_REPO="https://github.com/jmcorgan/fips.git"
|
||||
SB_REPO="https://git.laantungir.net/laantungir/sovereign_browser.git"
|
||||
FIPS_GITHUB_API="https://api.github.com/repos/jmcorgan/fips/releases/latest"
|
||||
FIPS_REPO="https://github.com/jmcorgan/fips.git"
|
||||
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}"
|
||||
BIN_NAME="sovereign_browser"
|
||||
|
||||
# Runtime apt packages (non-dev). git/build-essential/pkg-config are included
|
||||
# because they're needed for the FIPS source build and the source-build fallback.
|
||||
APT_PACKAGES=(
|
||||
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode0 libsqlite3-0
|
||||
libsecp256k1-1 libssl3 libcurl4 zlib1g
|
||||
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode4 libsqlite3-0
|
||||
libsecp256k1-2 libssl3t64 libcurl4t64 zlib1g
|
||||
tor git build-essential pkg-config
|
||||
libcap2-bin
|
||||
)
|
||||
# libclang-dev is only needed when building FIPS from source (bindgen).
|
||||
APT_PACKAGES_FIPS_SOURCE=(libclang-dev)
|
||||
|
||||
# --- Output helpers ------------------------------------------------------
|
||||
|
||||
@@ -75,21 +80,27 @@ Usage: curl -fsSL <url> | bash
|
||||
or: ./install.sh [options]
|
||||
|
||||
Options:
|
||||
--build-from-source Build from source instead of downloading prebuilt binary
|
||||
--prefix <dir> Install prefix (default: /usr/local)
|
||||
--yes Skip confirmation prompts
|
||||
-h, --help Show this help
|
||||
--build-from-source Build sovereign_browser from source instead of
|
||||
downloading the prebuilt binary
|
||||
--build-fips-from-source Build FIPS from source (cargo) instead of
|
||||
downloading the prebuilt binary. Slower; needs
|
||||
libclang-dev (auto-installed).
|
||||
--prefix <dir> Install prefix (default: /usr/local)
|
||||
--yes Skip confirmation prompts
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Args ----------------------------------------------------------------
|
||||
|
||||
BUILD_FROM_SOURCE=false
|
||||
BUILD_FIPS_FROM_SOURCE=false
|
||||
ASSUME_YES=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--build-from-source) BUILD_FROM_SOURCE=true; shift ;;
|
||||
--build-fips-from-source) BUILD_FIPS_FROM_SOURCE=true; shift ;;
|
||||
--prefix) INSTALL_PREFIX="${2:-}"; shift 2 ;;
|
||||
--yes|-y) ASSUME_YES=true; shift ;;
|
||||
-h|--help) show_usage; exit 0 ;;
|
||||
@@ -163,12 +174,131 @@ install_deps() {
|
||||
}
|
||||
|
||||
# --- FIPS ----------------------------------------------------------------
|
||||
#
|
||||
# FIPS is a Rust project (https://github.com/jmcorgan/fips). It publishes
|
||||
# prebuilt release tarballs on GitHub Releases containing the `fips` binary
|
||||
# plus tools (fipsctl, fipstop) and systemd units. We download the prebuilt
|
||||
# x86_64 Linux tarball by default — much faster than a cargo build.
|
||||
#
|
||||
# With --build-fips-from-source, we instead clone the repo and run
|
||||
# `cargo build --release` (requires Rust 1.94.1+ via rustup, and libclang-dev
|
||||
# for the rustables/bindgen nftables bindings).
|
||||
|
||||
# Download the prebuilt FIPS tarball from GitHub Releases and extract the
|
||||
# `fips` binary. Outputs a stable binary path on stdout (the file is copied
|
||||
# to a caller-managed temp file so it survives after this function returns).
|
||||
# Returns non-zero on failure so the caller can fall back to a source build.
|
||||
download_fips_prebuilt() {
|
||||
print_info "Querying GitHub for latest FIPS release..."
|
||||
local api_json tag asset_url
|
||||
if ! api_json="$(curl -fsSL "$FIPS_GITHUB_API" 2>/dev/null)"; then
|
||||
print_warning "Could not reach FIPS GitHub releases API."
|
||||
return 1
|
||||
fi
|
||||
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
print_warning "Could not parse tag_name from FIPS GitHub release JSON."
|
||||
return 1
|
||||
fi
|
||||
# Find the linux-x86_64 tarball asset URL.
|
||||
asset_url="$(printf '%s' "$api_json" | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*linux-x86_64\.tar\.gz"' | head -1 | sed -E 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$asset_url" ]]; then
|
||||
print_warning "No linux-x86_64 tarball found in FIPS release $tag."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use a temp dir for download + extraction. We do NOT use a RETURN trap
|
||||
# here because the caller needs to copy the binary out after this
|
||||
# function returns — a RETURN trap would delete the file first. Instead
|
||||
# we copy the binary to a stable temp file and clean up the extraction
|
||||
# dir ourselves before returning.
|
||||
local tmp fips_bin stable
|
||||
tmp="$(mktemp -d -t fips_dl.XXXXXX)"
|
||||
print_info "Downloading FIPS prebuilt binary: $asset_url"
|
||||
if ! curl -fsSL -o "$tmp/fips.tar.gz" "$asset_url"; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball download failed."
|
||||
return 1
|
||||
fi
|
||||
if ! tar -xzf "$tmp/fips.tar.gz" -C "$tmp" 2>/dev/null; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball extraction failed."
|
||||
return 1
|
||||
fi
|
||||
# The tarball extracts to fips-<ver>-linux-x86_64/fips
|
||||
fips_bin="$(find "$tmp" -type f -name fips 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$fips_bin" ]] || ! file "$fips_bin" 2>/dev/null | grep -qi ELF; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball did not contain an ELF 'fips' binary."
|
||||
return 1
|
||||
fi
|
||||
# Copy to a stable path so the caller can access it after we clean up
|
||||
# the extraction dir. mktemp gives a file the caller can rm later.
|
||||
stable="$(mktemp -t fips_binary.XXXXXX)"
|
||||
cp -f "$fips_bin" "$stable"
|
||||
chmod +x "$stable"
|
||||
rm -rf "$tmp"
|
||||
printf '%s' "$stable"
|
||||
}
|
||||
|
||||
# --- Rust toolchain (only for --build-fips-from-source) ------------------
|
||||
|
||||
FIPS_MIN_RUST_MAJOR=1
|
||||
FIPS_MIN_RUST_MINOR=94
|
||||
|
||||
rust_version_ok() {
|
||||
command -v cargo >/dev/null 2>&1 || return 1
|
||||
local ver
|
||||
ver="$(cargo --version 2>/dev/null | grep -oE 'cargo [0-9]+\.[0-9]+\.' | head -1 | sed 's/cargo //; s/\.$//')" || return 1
|
||||
[[ -z "$ver" ]] && return 1
|
||||
local major minor
|
||||
major="${ver%%.*}"
|
||||
minor="${ver#*.}"
|
||||
[[ "$major" -gt "$FIPS_MIN_RUST_MAJOR" ]] && return 0
|
||||
[[ "$major" -eq "$FIPS_MIN_RUST_MAJOR" && "$minor" -ge "$FIPS_MIN_RUST_MINOR" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_rust() {
|
||||
if rust_version_ok; then
|
||||
print_info "Rust toolchain OK ($(cargo --version))."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
print_warning "Installed cargo ($(cargo --version)) is older than FIPS requires (1.94.1+). Installing a newer toolchain via rustup."
|
||||
else
|
||||
print_info "No cargo found. Installing Rust toolchain via rustup."
|
||||
fi
|
||||
|
||||
local rustup_init
|
||||
rustup_init="$(mktemp -t rustup-init.XXXXXX)"
|
||||
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$rustup_init" https://sh.rustup.rs; then
|
||||
rm -f "$rustup_init"
|
||||
die "Failed to download rustup installer."
|
||||
fi
|
||||
if ! sh "$rustup_init" -y --profile minimal >/dev/null 2>&1; then
|
||||
rm -f "$rustup_init"
|
||||
die "rustup installation failed."
|
||||
fi
|
||||
rm -f "$rustup_init"
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! rust_version_ok; then
|
||||
die "Rust toolchain installed but cargo still reports an insufficient version. FIPS requires 1.94.1+."
|
||||
fi
|
||||
print_success "Rust toolchain installed: $(cargo --version)."
|
||||
}
|
||||
|
||||
# Build FIPS from source via cargo. Outputs the binary path on stdout.
|
||||
build_fips_from_source() {
|
||||
print_info "Building FIPS from source ($FIPS_REPO)..."
|
||||
ensure_rust
|
||||
|
||||
install_fips() {
|
||||
print_info "Building and installing FIPS from $FIPS_REPO (required, not optional)..."
|
||||
local tmp
|
||||
tmp="$(mktemp -d)"
|
||||
# Register cleanup for this temp dir (in addition to the global trap).
|
||||
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
|
||||
|
||||
if ! git clone --depth 1 "$FIPS_REPO" "$tmp/fips"; then
|
||||
@@ -176,40 +306,52 @@ install_fips() {
|
||||
fi
|
||||
|
||||
local fips_dir="$tmp/fips"
|
||||
local built=false
|
||||
|
||||
if [[ -x "$fips_dir/build.sh" ]]; then
|
||||
print_info "FIPS: running ./build.sh"
|
||||
if ( cd "$fips_dir" && ./build.sh ); then built=true; fi
|
||||
print_info "FIPS: running cargo build --release (this can take several minutes)..."
|
||||
if ! ( cd "$fips_dir" && cargo build --release ); then
|
||||
die "cargo build --release failed for FIPS. FIPS is required."
|
||||
fi
|
||||
|
||||
if ! $built && [[ -f "$fips_dir/Makefile" ]]; then
|
||||
print_info "FIPS: running make"
|
||||
if ( cd "$fips_dir" && make ); then built=true; fi
|
||||
fi
|
||||
|
||||
if ! $built && [[ -f "$fips_dir/CMakeLists.txt" ]]; then
|
||||
print_info "FIPS: running cmake"
|
||||
if ( cd "$fips_dir" && cmake -B build && cmake --build build ); then built=true; fi
|
||||
fi
|
||||
|
||||
if ! $built; then
|
||||
die "Could not build FIPS (no build.sh, Makefile, or CMakeLists.txt succeeded). FIPS is required."
|
||||
fi
|
||||
|
||||
# Locate the built fips binary.
|
||||
local fips_bin=""
|
||||
for cand in "$fips_dir/fips" "$fips_dir/build/fips" "$fips_dir/build/src/fips"; do
|
||||
for cand in \
|
||||
"$fips_dir/target/release/fips" \
|
||||
"$fips_dir/target/x86_64-unknown-linux-gnu/release/fips"; do
|
||||
if [[ -x "$cand" ]] && file "$cand" 2>/dev/null | grep -qi ELF; then
|
||||
fips_bin="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$fips_bin" ]]; then
|
||||
# Last resort: search for any executable named fips.
|
||||
fips_bin="$(find "$fips_dir" -type f -name fips -executable 2>/dev/null | head -1 || true)"
|
||||
fips_bin="$(find "$fips_dir/target" -type f -name fips -executable 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[[ -n "$fips_bin" ]] || die "FIPS build succeeded but no 'fips' binary was found."
|
||||
printf '%s' "$fips_bin"
|
||||
}
|
||||
|
||||
install_fips() {
|
||||
print_info "Installing FIPS (required, not optional)..."
|
||||
|
||||
# If source build was requested, install the extra build-time apt deps.
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
print_info "Installing FIPS source-build deps (libclang-dev for bindgen)..."
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
local fips_bin=""
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
else
|
||||
if ! fips_bin="$(download_fips_prebuilt)"; then
|
||||
print_warning "Prebuilt FIPS unavailable — falling back to source build."
|
||||
# Install source-build deps on the fallback path too.
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
fi
|
||||
fi
|
||||
[[ -n "$fips_bin" && -x "$fips_bin" ]] || die "FIPS binary not available."
|
||||
|
||||
local prefix_bin="$INSTALL_PREFIX/bin"
|
||||
local SUDO
|
||||
@@ -218,6 +360,10 @@ install_fips() {
|
||||
$SUDO cp -f "$fips_bin" "$prefix_bin/fips"
|
||||
$SUDO chmod +x "$prefix_bin/fips"
|
||||
print_success "FIPS binary installed to $prefix_bin/fips"
|
||||
# Clean up the temp binary (download_fips_prebuilt copies to a stable
|
||||
# mktemp file; build_fips_from_source uses a RETURN-trapped dir so its
|
||||
# output is already gone, but rm -f is safe on either).
|
||||
rm -f "$fips_bin" 2>/dev/null || true
|
||||
|
||||
# setcap for CAP_NET_ADMIN (needed to create a TUN interface in managed mode).
|
||||
local setcap_cmd=""
|
||||
@@ -231,7 +377,6 @@ install_fips() {
|
||||
if $setcap_cmd cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
# setcap exists but failed — try with sudo explicitly.
|
||||
if $SUDO setcap cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
@@ -413,18 +558,29 @@ main() {
|
||||
local method="prebuilt binary from Gitea"
|
||||
$BUILD_FROM_SOURCE && method="source build from $SB_REPO"
|
||||
|
||||
cat >&2 <<EOF
|
||||
${BLUE}[INFO]${NC} sovereign_browser will be installed:
|
||||
${BLUE}[INFO]${NC} Install prefix : $INSTALL_PREFIX
|
||||
${BLUE}[INFO]${NC} Binary method : $method
|
||||
${BLUE}[INFO]${NC} FIPS : built from source ($FIPS_REPO), CAP_NET_ADMIN set
|
||||
${BLUE}[INFO]${NC} Tor : apt package 'tor'
|
||||
${BLUE}[INFO]${NC} apt deps : ${APT_PACKAGES[*]}
|
||||
EOF
|
||||
local fips_method="prebuilt binary from GitHub releases"
|
||||
$BUILD_FIPS_FROM_SOURCE && fips_method="source build ($FIPS_REPO, cargo)"
|
||||
|
||||
# Use print_info so colors render via `echo -e` (a plain `cat` heredoc
|
||||
# would print the literal \033 escape sequences).
|
||||
print_info "sovereign_browser will be installed:"
|
||||
print_info " Install prefix : $INSTALL_PREFIX"
|
||||
print_info " Binary method : $method"
|
||||
print_info " FIPS : $fips_method, CAP_NET_ADMIN set"
|
||||
print_info " Tor : apt package 'tor'"
|
||||
print_info " apt deps : ${APT_PACKAGES[*]}"
|
||||
|
||||
if ! $ASSUME_YES; then
|
||||
echo -e "${YELLOW}[WARNING]${NC} This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N] " >&2
|
||||
read -r reply || reply=""
|
||||
print_warning "This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N]"
|
||||
# Read from /dev/tty, not stdin: in `curl | bash` mode stdin is the
|
||||
# curl pipe (the script text), so a plain `read` would hit EOF and
|
||||
# abort immediately. /dev/tty is the user's terminal.
|
||||
if [[ -r /dev/tty ]]; then
|
||||
read -r reply < /dev/tty || reply=""
|
||||
else
|
||||
# No tty available (e.g. non-interactive CI) — require --yes.
|
||||
die "No tty available for confirmation. Re-run with --yes to skip prompts."
|
||||
fi
|
||||
reply="${reply:-n}"
|
||||
if [[ ! "${reply,,}" =~ ^y(es)?$ ]]; then
|
||||
die "Aborted by user."
|
||||
@@ -450,8 +606,9 @@ EOF
|
||||
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
plans/bookmarks-tree-view.md
Normal file
261
plans/bookmarks-tree-view.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
|
||||
|
||||
## Goal
|
||||
|
||||
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
|
||||
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
|
||||
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
|
||||
|
||||
## NIP-51 Compliance + Privacy — How Nesting Works
|
||||
|
||||
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
|
||||
|
||||
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
|
||||
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
|
||||
|
||||
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
|
||||
|
||||
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
|
||||
|
||||
```
|
||||
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
|
||||
```
|
||||
|
||||
### Event layout (one per folder)
|
||||
|
||||
```
|
||||
{
|
||||
"kind": 30003,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The NIP-44 encrypted `content` plaintext is:
|
||||
|
||||
```json
|
||||
{"path": "Work/Projects/Secret", "bookmarks": [
|
||||
["bookmark", "https://example.com", "Example", 1690000000]
|
||||
]}
|
||||
```
|
||||
|
||||
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
|
||||
|
||||
### Tree reconstruction (client-side)
|
||||
|
||||
1. Fetch all `kind=30003` events for the user's pubkey.
|
||||
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
|
||||
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
|
||||
4. Attach the decrypted `bookmarks` array to that leaf node.
|
||||
|
||||
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
|
||||
|
||||
### Why this is safe and nostr-ish
|
||||
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
|
||||
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
|
||||
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
|
||||
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
|
||||
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
|
||||
B --> C["Read path from plaintext JSON"]
|
||||
C --> D["Split path on / to get segments"]
|
||||
D --> E["Build trie of folders via node_ensure_path"]
|
||||
E --> F["Attach decrypted bookmarks to leaf node"]
|
||||
F --> G["Render tree on sovereign://bookmarks page"]
|
||||
G --> H["User adds/moves/renames/deletes"]
|
||||
H --> I["Compute HMAC d tag for affected path(s)"]
|
||||
I --> J["NIP-44 encrypt new content and publish kind 30003"]
|
||||
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
|
||||
|
||||
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
|
||||
|
||||
#### New struct
|
||||
|
||||
```c
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last segment of path, e.g. "Secret" */
|
||||
char *path; /* full path, e.g. "Work/Projects/Secret" */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_t;
|
||||
```
|
||||
|
||||
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
|
||||
|
||||
#### New / changed API
|
||||
|
||||
Keep the existing public functions working (they now operate on paths, not flat names):
|
||||
|
||||
- `bookmarks_add(const char *path, const char *url, const char *title)` — `path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
|
||||
- `bookmarks_remove(const char *path, const char *url)`
|
||||
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
|
||||
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
|
||||
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
|
||||
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
|
||||
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
|
||||
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
|
||||
|
||||
#### Internal helpers
|
||||
|
||||
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
|
||||
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
|
||||
- `static void node_free(bookmark_node_t *node)` — recursive free.
|
||||
- `static void compute_hmac_key(unsigned char out[32])` — `HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
|
||||
- `static char *path_to_d_tag(const char *path)` — `HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
|
||||
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
|
||||
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
|
||||
|
||||
#### Loading
|
||||
|
||||
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
|
||||
1. Read the `d` tag.
|
||||
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
|
||||
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
|
||||
4. `node_ensure_path(root, path)`.
|
||||
5. Load bookmarks into that leaf.
|
||||
|
||||
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
|
||||
|
||||
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"have_signer": true,
|
||||
"tree": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"bookmarks": [],
|
||||
"children": [
|
||||
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
|
||||
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
|
||||
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
|
||||
|
||||
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
|
||||
|
||||
#### HTML
|
||||
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
|
||||
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
|
||||
- Add a path breadcrumb / parent selector in the add form.
|
||||
|
||||
#### JS
|
||||
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
|
||||
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
|
||||
- A bookmark list under the folder (collapsible).
|
||||
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
|
||||
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
|
||||
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
|
||||
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
|
||||
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
|
||||
|
||||
#### CSS
|
||||
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
|
||||
- Caret uses a CSS triangle that rotates 90° when expanded.
|
||||
- Indent `.tree-children` by `20px` per level.
|
||||
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
|
||||
|
||||
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
|
||||
|
||||
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
|
||||
|
||||
#### Layout
|
||||
|
||||
```
|
||||
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
|
||||
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
|
||||
[ ============ WebKitWebView ============ ]
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
|
||||
```c
|
||||
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
|
||||
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
|
||||
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
```
|
||||
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
|
||||
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
|
||||
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
|
||||
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
|
||||
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
|
||||
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
|
||||
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
|
||||
|
||||
#### Cross-tab refresh
|
||||
|
||||
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
|
||||
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
|
||||
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
|
||||
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
|
||||
|
||||
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
|
||||
|
||||
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
|
||||
|
||||
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
|
||||
|
||||
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
|
||||
|
||||
### 6. History filter — [`src/history.c`](src/history.c:27)
|
||||
|
||||
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
|
||||
|
||||
### 7. Tests
|
||||
|
||||
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
|
||||
- `node_ensure_path` creates intermediate nodes.
|
||||
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
|
||||
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
|
||||
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
|
||||
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
|
||||
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
|
||||
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
|
||||
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
|
||||
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
|
||||
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
|
||||
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
|
||||
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
|
||||
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
|
||||
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
|
||||
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
|
||||
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
|
||||
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |
|
||||
465
plans/fips-management-page.md
Normal file
465
plans/fips-management-page.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# FIPS Management Page — `sovereign://fips`
|
||||
|
||||
## Goal
|
||||
|
||||
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
|
||||
inside the browser. This is **not** part of the settings page — it's a
|
||||
separate status + management page, like a network control panel.
|
||||
|
||||
The page lets the user:
|
||||
|
||||
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
|
||||
tree state, ownership (managed vs attached), error messages.
|
||||
2. **See connected peers** — list of peers with their npub, address,
|
||||
transport, and connection state.
|
||||
3. **Connect to a peer** — add a new peer by npub + address + transport.
|
||||
4. **Disconnect a peer** — remove a peer connection.
|
||||
5. **Restart the FIPS service** — stop + start the managed daemon, or
|
||||
re-attach.
|
||||
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
|
||||
services, showing both on one "Network" page makes sense. Tor is
|
||||
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
|
||||
interactive management.
|
||||
|
||||
This makes `.fips` addresses actually usable: the user can add peers from
|
||||
the browser UI instead of dropping to `fipsctl` on the command line.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
|
||||
|
||||
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
|
||||
The browser already has a client:
|
||||
|
||||
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
|
||||
connection to the socket.
|
||||
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
|
||||
`{"command":"show_status"}`, parses response into `fips_status_t`
|
||||
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
|
||||
tun_active).
|
||||
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
|
||||
`{"command":"show_peers"}`, returns the `data` field as a JSON string
|
||||
(caller frees). **This is not currently called anywhere** — it exists
|
||||
but is unused.
|
||||
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
|
||||
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
|
||||
**Also unused.**
|
||||
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
|
||||
|
||||
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
|
||||
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
|
||||
needs verification against the FIPS daemon's control protocol — see
|
||||
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
|
||||
`fips_control_disconnect_peer()` function.
|
||||
|
||||
### Net services layer (`src/net_services.c` / `src/net_services.h`)
|
||||
|
||||
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
|
||||
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
|
||||
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
|
||||
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
|
||||
`control_socket[512]`.
|
||||
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
|
||||
(managed) or 5s (attached) and updates the struct. It does **not** poll
|
||||
`show_peers` — only the peer *count* is tracked.
|
||||
- The service `state` field (`service_state_t`) tracks the lifecycle:
|
||||
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
|
||||
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
|
||||
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
|
||||
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
|
||||
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
|
||||
it disables then re-enables the service.
|
||||
|
||||
### sovereign:// page pattern (`src/nostr_bridge.c`)
|
||||
|
||||
The existing pages (settings, profile, bookmarks, agents) follow this
|
||||
pattern:
|
||||
|
||||
1. **HTML page** served from an embedded `www/` file via
|
||||
[`serve_embedded_file()`](src/nostr_bridge.c:132).
|
||||
2. **CSS** served from `www/<name>.css`, linked via
|
||||
`<link href="sovereign://fips.css">`.
|
||||
3. **JS** served from `www/<name>.js`, loaded via
|
||||
`<script src="sovereign://fips.js">`.
|
||||
4. **JSON data endpoints** — `sovereign://fips/status` returns JSON,
|
||||
fetched by the JS on page load and on refresh.
|
||||
5. **Action endpoints** — `sovereign://fips/connect?npub=...&address=...&transport=...`
|
||||
performs an action and returns JSON.
|
||||
|
||||
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
|
||||
scheme handler doesn't support `fetch()` reliably for `sovereign://`
|
||||
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
|
||||
A `sovereignGet()` helper wraps XHR and returns a Promise.
|
||||
|
||||
Routing is in the main `bridge_handle_request()` function
|
||||
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
|
||||
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Status ──────────────────────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Node: npub1abc...xyz │ │
|
||||
│ │ TUN: fips0 (active) │ │
|
||||
│ │ Peers: 3 connected │ │
|
||||
│ │ Tree: healthy │ │
|
||||
│ │ Ownership: managed (spawned by browser) │ │
|
||||
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
|
||||
│ │ [Restart Service] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Peers ───────────────────────────────────────────────┐ │
|
||||
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
|
||||
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
|
||||
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
|
||||
│ │ npub: [npub1... ] │ │
|
||||
│ │ address: [host:port ] │ │
|
||||
│ │ transport: [udp ▼] │ │
|
||||
│ │ [Connect] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Bootstrap: 100% │ │
|
||||
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
|
||||
│ │ Ownership: attached (system Tor) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Routing
|
||||
|
||||
| URI | Handler | Returns |
|
||||
|-----|---------|---------|
|
||||
| `sovereign://fips` | serve `www/fips.html` | HTML page |
|
||||
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
|
||||
| `sovereign://fips.js` | serve `www/fips.js` | JS |
|
||||
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
|
||||
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
|
||||
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
|
||||
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
|
||||
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
|
||||
|
||||
### JSON endpoints
|
||||
|
||||
#### `sovereign://fips/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"fips": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "managed",
|
||||
"node_npub": "npub1abc...",
|
||||
"peer_count": 3,
|
||||
"tun_active": true,
|
||||
"tun_name": "fips0",
|
||||
"tree_state": "healthy",
|
||||
"daemon_state": "running",
|
||||
"control_socket": "~/.sovereign_browser/fips/control.sock",
|
||||
"error": null
|
||||
},
|
||||
"tor": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "attached",
|
||||
"bootstrap_progress": 100,
|
||||
"socks_endpoint": "socks5://127.0.0.1:9050",
|
||||
"circuit_info": "",
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a service is `DISABLED` or `FAILED`, the fields are populated as
|
||||
best as possible and `error` holds the failure message.
|
||||
|
||||
#### `sovereign://fips/peers`
|
||||
|
||||
Returns the raw `show_peers` data from the FIPS daemon (passed through
|
||||
as-is, since the format is defined by FIPS):
|
||||
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
|
||||
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
|
||||
|
||||
#### `sovereign://fips/connect`
|
||||
|
||||
Query params: `npub`, `address`, `transport` (optional, defaults to
|
||||
`udp`).
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/disconnect`
|
||||
|
||||
Query params: `npub`.
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/restart`
|
||||
|
||||
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
|
||||
`{"status": "ok"}` (the restart is async — the JS polls
|
||||
`sovereign://fips/status` to see when it's ready again).
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
|
||||
|
||||
```c
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
```
|
||||
|
||||
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
|
||||
exact command name** against the FIPS daemon's control protocol — check
|
||||
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
|
||||
`disconnect_peer` or `remove_peer`, adjust accordingly.
|
||||
|
||||
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
|
||||
|
||||
New functions (following the existing handler pattern):
|
||||
|
||||
- `handle_fips_status_json(request)` — reads
|
||||
`net_service_get_status(NET_SERVICE_FIPS)` and
|
||||
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
|
||||
the fields above, responds as JSON.
|
||||
- `handle_fips_peers_json(request)` — opens a control connection to the
|
||||
FIPS socket (from `status.fips.control_socket`), calls
|
||||
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
|
||||
responds as JSON. If FIPS is not ready, returns empty peers + error.
|
||||
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
|
||||
`transport` from query string, opens a control connection, calls
|
||||
`fips_control_connect_peer()`, responds as JSON.
|
||||
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
|
||||
control connection, calls `fips_control_disconnect_peer()`, responds
|
||||
as JSON.
|
||||
- `handle_fips_restart(request)` — calls
|
||||
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
|
||||
|
||||
**Control connection management:** Each action handler opens a fresh
|
||||
control connection to the FIPS socket, sends the command, closes the
|
||||
connection. This is simpler than reusing the `net_service_t`'s
|
||||
`control_fd` (which is owned by the poll callback and may be in use).
|
||||
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
|
||||
|
||||
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
|
||||
|
||||
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
|
||||
line 3606), following the same pattern as the settings/bookmarks/agents
|
||||
blocks:
|
||||
|
||||
```c
|
||||
/* Route sovereign://fips — FIPS mesh management page. */
|
||||
if (strcmp(uri, "sovereign://fips") == 0 ||
|
||||
strncmp(uri, "sovereign://fips?", 18) == 0) {
|
||||
serve_embedded_file(request, "fips.html");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.css") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
|
||||
serve_embedded_file(request, "fips.css");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.js") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
|
||||
serve_embedded_file(request, "fips.js");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/status") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
|
||||
handle_fips_status_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
|
||||
handle_fips_peers_json(request);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
|
||||
handle_fips_connect(request, uri + 24);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
|
||||
handle_fips_disconnect(request, uri + 27);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
|
||||
handle_fips_restart(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create `www/fips.html`
|
||||
|
||||
The HTML page. Links `fips.css` and `fips.js`. Structure:
|
||||
|
||||
- Status section (populated by JS from `sovereign://fips/status`)
|
||||
- Peers table (populated by JS from `sovereign://fips/peers`)
|
||||
- Add peer form (npub, address, transport dropdown, Connect button)
|
||||
- Tor status section (read-only, from the same status JSON)
|
||||
- Refresh button (re-fetches status + peers)
|
||||
|
||||
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
|
||||
`sovereign-base.css` for theme variables, monospace font, dark theme.
|
||||
|
||||
### 5. Create `www/fips.css`
|
||||
|
||||
Page-specific styles. Import the base theme:
|
||||
```css
|
||||
@import "sovereign://sovereign-base.css";
|
||||
```
|
||||
|
||||
Style the status cards, peers table, add-peer form. Match the existing
|
||||
sovereign:// page aesthetic.
|
||||
|
||||
### 6. Create `www/fips.js`
|
||||
|
||||
The page logic:
|
||||
|
||||
```javascript
|
||||
/* On load: fetch status + peers, populate DOM.
|
||||
* Refresh button: re-fetch.
|
||||
* Connect form: POST to sovereign://fips/connect, then refresh peers.
|
||||
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
|
||||
* Restart button: GET sovereign://fips/restart, then poll status.
|
||||
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
|
||||
*/
|
||||
```
|
||||
|
||||
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
|
||||
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
|
||||
|
||||
### 7. Add a link to the FIPS page from the settings page
|
||||
|
||||
In `www/settings.html` (or `www/settings.js`), add a link:
|
||||
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
|
||||
section. This makes the page discoverable.
|
||||
|
||||
### 8. No Makefile changes needed
|
||||
|
||||
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
|
||||
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
|
||||
sufficient — no Makefile edit required.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
www/
|
||||
├── fips.html # FIPS management page (new)
|
||||
├── fips.css # Page styles (new)
|
||||
├── fips.js # Page logic (new)
|
||||
src/
|
||||
├── nostr_bridge.c # Add FIPS route handlers + routing
|
||||
├── fips_control.c # Add fips_control_disconnect_peer()
|
||||
├── fips_control.h # Add disconnect_peer() declaration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Status page (read-only)
|
||||
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
|
||||
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
|
||||
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
|
||||
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
|
||||
5. Show Tor status (read-only)
|
||||
6. Auto-refresh every 5s
|
||||
7. Add link from settings page
|
||||
|
||||
### Phase 2: Peer management (interactive)
|
||||
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
|
||||
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
|
||||
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
|
||||
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
|
||||
5. Add peers table + add-peer form + disconnect buttons to the page
|
||||
6. Test: connect to a known FIPS peer, verify it appears in the list,
|
||||
verify `.fips` URLs to that peer resolve in the browser
|
||||
|
||||
### Phase 3: Service control
|
||||
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
|
||||
2. Add restart button to the page
|
||||
3. Poll status during restart (state transitions: READY → STOPPING →
|
||||
DISCOVERING → STARTING → READY)
|
||||
4. Show error messages when state is FAILED
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Copy-to-clipboard for node npub
|
||||
2. QR code for node npub (reuse `sovereign://qr?text=...`)
|
||||
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
|
||||
4. Transport auto-detect (try udp, then tcp)
|
||||
5. Address book — save known peers in SQLite for re-connecting after
|
||||
restart
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build and start browser
|
||||
2. Navigate to `sovereign://fips`
|
||||
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
|
||||
4. Verify Tor status shows correctly
|
||||
5. Verify auto-refresh updates peer count if peers change
|
||||
6. If FIPS is not running:
|
||||
- Verify status shows "disabled" or "failed" with error message
|
||||
- Verify peers list is empty with "FIPS not ready" message
|
||||
7. Add a peer:
|
||||
- Enter a known FIPS peer's npub + address + transport
|
||||
- Click Connect
|
||||
- Verify peer appears in the peers list
|
||||
- Verify `http://<peer-npub>.fips/` loads in a tab
|
||||
8. Disconnect a peer:
|
||||
- Click the ✕ next to a peer
|
||||
- Verify peer disappears from the list
|
||||
9. Restart FIPS:
|
||||
- Click Restart Service
|
||||
- Verify state transitions through stopping → starting → ready
|
||||
- Verify peers reconnect (if persistent) or need re-adding
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **FIPS disconnect command name** — is it `disconnect`,
|
||||
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
|
||||
control protocol docs or `fipsctl` source. The `connect` command is
|
||||
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
|
||||
assumed.
|
||||
|
||||
2. **Peer persistence** — when the browser restarts the managed FIPS
|
||||
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
|
||||
does the user need to re-add them every time? If non-persistent,
|
||||
Phase 4's "address book" (saving peers in SQLite) becomes more
|
||||
important.
|
||||
|
||||
3. **FIPS daemon control protocol docs** — the `show_peers` response
|
||||
format is passed through as-is. We should verify the field names
|
||||
(`npub`, `address`, `transport`, `state`) match what the JS expects.
|
||||
If the format differs, the JS can adapt, but it's better to know
|
||||
upfront.
|
||||
255
plans/fips-network-view.md
Normal file
255
plans/fips-network-view.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# FIPS Network View — `sovereign://fips/network`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the FIPS management page with a "Network" view that shows the
|
||||
**entire mesh** as the local node knows it — not just direct peers, but
|
||||
every node the daemon has discovered via bloom-filter propagation and
|
||||
Nostr discovery. This answers "what can you see on FIPS?" and "can you
|
||||
get a view of the entire network?"
|
||||
|
||||
## What the FIPS daemon exposes
|
||||
|
||||
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
|
||||
these read-only commands relevant to a network view:
|
||||
|
||||
| Command | What it returns | Network-view use |
|
||||
|---------|----------------|------------------|
|
||||
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
|
||||
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
|
||||
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
|
||||
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
|
||||
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
|
||||
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
|
||||
|
||||
### Key insight: "the entire network" = identity cache + tree
|
||||
|
||||
The FIPS daemon does **not** have a "ask peer X what peers it has"
|
||||
command. The mesh is observed through two mechanisms:
|
||||
|
||||
1. **Bloom filters** — each node broadcasts a bloom filter of node
|
||||
addresses it knows about. The local node unions these to estimate
|
||||
total mesh size (`estimated_mesh_size`) and caches individual node
|
||||
identities it has resolved (`show_identity_cache`).
|
||||
|
||||
2. **Spanning tree** — each node declares a parent; the tree
|
||||
coordinates encode paths to the root. `show_tree` gives the local
|
||||
node's tree position and its neighbors' positions.
|
||||
|
||||
So "the entire network" as seen from this node is:
|
||||
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
|
||||
last-seen). This is the node list for the network view.
|
||||
- **`show_tree`** → the local tree topology (who is my parent, who are
|
||||
my children, what is the root, what is the depth).
|
||||
- **`show_status.estimated_mesh_size`** → a single estimated total.
|
||||
|
||||
We **cannot** build a full graph of who-connects-to-whom from the local
|
||||
node's control socket alone — that would require every node to expose
|
||||
its peer list, and FIPS doesn't do that (bloom filters propagate
|
||||
*existence*, not *adjacency*). What we can show is:
|
||||
|
||||
- A **node list** (identity cache) — "these are all the nodes in the
|
||||
mesh that this node knows about."
|
||||
- A **tree view** — "this is my position in the spanning tree, my
|
||||
parent, my children, the root."
|
||||
- A **mesh summary** — "estimated N nodes total, tree depth D."
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
Add a "Network" tab/section to `sovereign://fips` (or a separate
|
||||
`sovereign://fips/network` page linked from the main FIPS page).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
│ [Status] [Peers] [Network] [Tree] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
|
||||
│ │ Estimated mesh size: 47 nodes │ │
|
||||
│ │ Known nodes (cache): 23 │ │
|
||||
│ │ Tree depth: 4 │ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our position: depth 2, parent npub1def... │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
|
||||
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
|
||||
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
|
||||
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
|
||||
│ │ ... │ │
|
||||
│ │ (● = direct peer, ○ = known via discovery only) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our parent: npub1def... (fips.v0l.io) │ │
|
||||
│ │ Our children: │ │
|
||||
│ │ npub1jkl... depth 3 distance 1 │ │
|
||||
│ │ npub1mno... depth 3 distance 1 │ │
|
||||
│ │ Tree peers (1-hop): │ │
|
||||
│ │ npub1abc... depth 0 root distance 2 │ │
|
||||
│ │ npub1def... depth 1 parent distance 1 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### New JSON endpoints
|
||||
|
||||
| URI | FIPS command | Returns |
|
||||
|-----|-------------|---------|
|
||||
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
|
||||
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
|
||||
|
||||
#### `sovereign://fips/network`
|
||||
|
||||
Calls three FIPS commands on a fresh control connection and combines
|
||||
the results:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"estimated_mesh_size": 47,
|
||||
"known_nodes": 23,
|
||||
"max_cache_entries": 256,
|
||||
"tree_depth": 4,
|
||||
"is_root": false,
|
||||
"root_npub": "npub1abc...",
|
||||
"root_display_name": "laantungir",
|
||||
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
|
||||
"our_parent": "npub1def...",
|
||||
"our_parent_display_name": "fips.v0l.io"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_addr": "e3da8293...",
|
||||
"npub": "npub1abc...",
|
||||
"display_name": "laantungir",
|
||||
"ipv6_addr": "fde3:da82:...",
|
||||
"last_seen_ms": 1784309108403,
|
||||
"age_ms": 2000,
|
||||
"is_direct_peer": true
|
||||
}
|
||||
],
|
||||
"tree": {
|
||||
"root": "1b4788b7...",
|
||||
"root_npub": "npub1abc...",
|
||||
"is_root": false,
|
||||
"depth": 2,
|
||||
"parent": "9d1192e8...",
|
||||
"parent_display_name": "fips.v0l.io",
|
||||
"peers": [
|
||||
{
|
||||
"node_addr": "1b4788b7...",
|
||||
"display_name": "laantungir",
|
||||
"depth": 0,
|
||||
"distance_to_us": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `is_direct_peer` flag on each node is computed by cross-referencing
|
||||
the identity cache entries against the `show_peers` npub list (so the
|
||||
UI can mark direct peers vs. discovery-only nodes).
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
|
||||
|
||||
These follow the existing `fips_control_show_peers()` pattern — send
|
||||
the command, return the `data` field as a JSON string (caller frees):
|
||||
|
||||
```c
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
```
|
||||
|
||||
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
|
||||
|
||||
Opens a control connection, calls `show_status`, `show_peers`,
|
||||
`show_identity_cache`, and `show_tree`, then combines them into the
|
||||
JSON above. The `is_direct_peer` flag is computed by building a set of
|
||||
direct-peer npubs from `show_peers` and checking each identity-cache
|
||||
entry against it.
|
||||
|
||||
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
|
||||
|
||||
```c
|
||||
if (strcmp(uri, "sovereign://fips/network") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
|
||||
handle_fips_network_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
|
||||
handle_fips_tree_json(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
|
||||
|
||||
Add tab navigation (Status | Peers | Network | Tree) to the page. The
|
||||
Network tab fetches `sovereign://fips/network` and renders:
|
||||
- Mesh summary card (estimated size, known nodes, depth, root, our
|
||||
position).
|
||||
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
|
||||
- Spanning tree card (root, parent, children, 1-hop peers with
|
||||
distances).
|
||||
|
||||
The Tree tab fetches `sovereign://fips/tree` and renders a more
|
||||
detailed tree view (coords paths, declaration sequence).
|
||||
|
||||
#### 5. Auto-refresh
|
||||
|
||||
The Network tab polls `sovereign://fips/network` every 5s (same as the
|
||||
status poll).
|
||||
|
||||
## Limitations (what we cannot show)
|
||||
|
||||
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
|
||||
Bloom filters propagate node *existence*, not *edges*. To build a
|
||||
full graph, every node would need to expose its peer list (a future
|
||||
FIPS protocol extension), or the browser would need to query each
|
||||
node's control socket individually (not practical across the mesh).
|
||||
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
|
||||
and `age_ms`, so we can show staleness, but there's no event stream
|
||||
for join/leave (would need a FIPS control subscription protocol).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── fips_control.c # Add show_tree(), show_identity_cache()
|
||||
├── fips_control.h # Add declarations
|
||||
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
|
||||
www/
|
||||
├── fips.html # Add Network + Tree tabs
|
||||
├── fips.css # Tab + network-table styles
|
||||
├── fips.js # Network + tree rendering, tab switching
|
||||
```
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: Network summary + known nodes
|
||||
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
|
||||
2. Add `handle_fips_network_json()` combining status + peers + identity cache
|
||||
3. Add `sovereign://fips/network` route
|
||||
4. Add "Network" tab to the page with mesh summary + known-nodes table
|
||||
5. Mark direct peers with a badge
|
||||
|
||||
### Phase 2: Spanning tree view
|
||||
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
|
||||
2. Add `sovereign://fips/tree` route
|
||||
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
|
||||
4. Optional: simple ASCII/indented tree rendering
|
||||
|
||||
### Phase 3: Polish
|
||||
1. Sort known nodes by last-seen (most recent first)
|
||||
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
|
||||
3. Click a node to see its detail (ipv6, age, whether direct peer)
|
||||
4. Copy-to-clipboard for npub / ipv6
|
||||
5. Optional: bloom-filter visualization (advanced/debug tab)
|
||||
295
plans/processes-window.md
Normal file
295
plans/processes-window.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Processes Window — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
A `sovereign://processes` internal page that lets the user pinpoint which
|
||||
open tab is consuming CPU and dig into *what that page is doing*. Primary
|
||||
use case: diagnosing `~/lt/client`, which currently burns CPU across many
|
||||
tabs.
|
||||
|
||||
## The core technical reality
|
||||
|
||||
The codebase deliberately **shares one WebProcess across tabs** — every
|
||||
new tab/webview is created with `webkit_web_view_new_with_related_view()`
|
||||
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
|
||||
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
|
||||
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
|
||||
|
||||
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
|
||||
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
|
||||
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
|
||||
|
||||
The plan therefore uses **two layers**:
|
||||
|
||||
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
|
||||
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
|
||||
the single-tab-per-process case and shows overall footprint.
|
||||
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
|
||||
is what actually pinpoints the offending tab *within* a shared
|
||||
WebProcess and tells you what it is doing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BrowserProcess[sovereign_browser main PID]
|
||||
ProcInfo[process_info.c reads /proc]
|
||||
Bridge[nostr_bridge.c sovereign:// routes]
|
||||
AgentServer[agent_server / MCP]
|
||||
end
|
||||
|
||||
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
|
||||
ProbeA[perf-probe.js in tab A]
|
||||
ProbeB[perf-probe.js in tab B]
|
||||
ProbeC[perf-probe.js in tab C]
|
||||
end
|
||||
|
||||
ProbeA -->|sovereign://processes/probe-report POST| Bridge
|
||||
ProbeB -->|sovereign://processes/probe-report POST| Bridge
|
||||
ProbeC -->|sovereign://processes/probe-report POST| Bridge
|
||||
|
||||
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
|
||||
Bridge -->|JSON| UI[www/processes.js renders tables]
|
||||
Bridge -->|JSON| AgentServer
|
||||
```
|
||||
|
||||
## Layer 1 — Process view (`src/process_info.{c,h}`)
|
||||
|
||||
### Process discovery
|
||||
|
||||
| Process | How found |
|
||||
|---|---|
|
||||
| Main (`sovereign_browser`) | `getpid()` |
|
||||
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
|
||||
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
|
||||
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
|
||||
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
|
||||
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
|
||||
|
||||
### Per-process fields (all from `/proc`, no external deps)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| pid | — |
|
||||
| name | `/proc/<pid>/comm` |
|
||||
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
|
||||
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
|
||||
| ppid | `/proc/<pid>/stat` field 4 |
|
||||
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
|
||||
| rss_kb | `/proc/<pid>/status` → `VmRSS` |
|
||||
| pss_kb | `/proc/<pid>/smaps_rollup` → `Pss` (fallback to RSS if unavailable) |
|
||||
| uss_kb | `/proc/<pid>/smaps_rollup` → `Private_Clean` + `Private_Dirty` |
|
||||
| vmpeak_kb | `/proc/<pid>/status` → `VmPeak` |
|
||||
| vmswap_kb | `/proc/<pid>/status` → `VmSwap` |
|
||||
| threads | count of entries in `/proc/<pid>/task/` |
|
||||
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
|
||||
| io_read_kb | `/proc/<pid>/io` → `read_bytes` / 1024 |
|
||||
| io_write_kb | `/proc/<pid>/io` → `write_bytes` / 1024 |
|
||||
| fd_count | count of entries in `/proc/<pid>/fd/` |
|
||||
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
|
||||
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
|
||||
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
|
||||
|
||||
### CPU% computation
|
||||
|
||||
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
|
||||
1. Read current `utime+stime` and `time(NULL)`.
|
||||
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
|
||||
3. Store current as prev.
|
||||
|
||||
Caller polls `sovereign://processes/list` every 1s; first call returns
|
||||
0% (no baseline), second call onward is accurate. Same approach as
|
||||
`top`/`htop`.
|
||||
|
||||
### API
|
||||
|
||||
```c
|
||||
/* src/process_info.h */
|
||||
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
|
||||
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
|
||||
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
|
||||
```
|
||||
|
||||
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
|
||||
|
||||
Injected into every webview via `WebKitUserContentManager` (same pattern
|
||||
as the existing `window.nostr` injection in
|
||||
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
|
||||
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
|
||||
`WebKitUserScriptInjectAtDocumentStart`).
|
||||
|
||||
### Signals collected
|
||||
|
||||
| Signal | Source | What it tells you |
|
||||
|---|---|---|
|
||||
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
|
||||
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
|
||||
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
|
||||
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
|
||||
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
|
||||
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
|
||||
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
|
||||
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
|
||||
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
|
||||
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
|
||||
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
|
||||
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
|
||||
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
|
||||
|
||||
### Reporting
|
||||
|
||||
The probe batches a report every 1s and POSTs it to
|
||||
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
|
||||
route handler in `nostr_bridge.c` calls
|
||||
`process_info_record_probe(N, body)`, which stores the latest probe per
|
||||
tab index in a static array (sized to `tab_manager_count()`).
|
||||
|
||||
The probe must NOT run on `sovereign://` internal pages (settings, fips,
|
||||
processes itself, agents) — skip injection when the URL scheme is
|
||||
`sovereign://`. This avoids self-noise and recursion.
|
||||
|
||||
### Probe payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"tab_index": 3,
|
||||
"cpu_busy_percent": 42.5,
|
||||
"fps": 12,
|
||||
"timer_count": 38,
|
||||
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
|
||||
"net_in_flight": 3,
|
||||
"net_requests_last_sec": 8,
|
||||
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
|
||||
"heap_used_mb": 124.3,
|
||||
"event_listener_count": 217,
|
||||
"worker_count": 2,
|
||||
"dom_node_count": 4821,
|
||||
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
|
||||
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
|
||||
}
|
||||
```
|
||||
|
||||
## Routes (added to `nostr_bridge.c`)
|
||||
|
||||
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
|
||||
|
||||
| Route | Handler | Returns |
|
||||
|---|---|---|
|
||||
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
|
||||
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
|
||||
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
|
||||
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
|
||||
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
|
||||
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
|
||||
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
|
||||
|
||||
## UI (`www/processes.{html,css,js}`)
|
||||
|
||||
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
|
||||
|
||||
### Tab 1 — Processes
|
||||
|
||||
Sortable table, default sort by `cpu_percent` desc:
|
||||
|
||||
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
|
||||
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
|
||||
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
|
||||
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
|
||||
|
||||
Click a WebKitWebProcess row → expands inline to list its hosted tabs
|
||||
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
|
||||
hot tab even within a shared process.
|
||||
|
||||
### Tab 2 — Tabs (the primary diagnostic view)
|
||||
|
||||
Sortable table, default sort by `cpu_busy_percent` desc:
|
||||
|
||||
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
|
||||
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
|
||||
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
|
||||
|
||||
Click a tab row → **drill-down panel** opens below with:
|
||||
- Long-task timeline (last 30s, sparkline)
|
||||
- Top long-task sources (script URLs + total ms)
|
||||
- Active timers list (code label, interval, count)
|
||||
- Recent network requests (URL, type, count)
|
||||
- Worker count, event listener count, heap trend
|
||||
- Action buttons: **Reload tab**, **Suspend tab** (replace with
|
||||
`about:blank` keeping URL), **Close tab**
|
||||
|
||||
### Styling
|
||||
|
||||
Reuse `sovereign-base.css` and the card/table styles from
|
||||
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
|
||||
(green→yellow→red by CPU%) for instant visual scan.
|
||||
|
||||
## Menu integration
|
||||
|
||||
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
|
||||
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
|
||||
(or opens a new tab if none active) — same pattern as
|
||||
`open_fips_cb` at line 332.
|
||||
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
|
||||
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
|
||||
`open_processes`.
|
||||
|
||||
## MCP tools (so an agent can self-diagnose)
|
||||
|
||||
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
|
||||
[`agent_mcp.c`](../src/agent_mcp.c:1):
|
||||
|
||||
| Tool | Args | Returns |
|
||||
|---|---|---|
|
||||
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
|
||||
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
|
||||
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
|
||||
|
||||
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
|
||||
and tell me what it's doing" — the agent calls `processes.tabs`, finds
|
||||
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
|
||||
drill-down, and reports the offending script/timer/endpoint.
|
||||
|
||||
## Build / packaging
|
||||
|
||||
- Add `src/process_info.c` to `Makefile` sources.
|
||||
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
|
||||
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
|
||||
into the binary via `serve_embedded_file()`.
|
||||
- Register the perf-probe user script in the shared
|
||||
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
|
||||
`main.c` near where `nostr_inject` is wired), with a URL filter that
|
||||
excludes `sovereign://*`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make`
|
||||
2. `./browser.sh restart --login-method generate`
|
||||
3. Open `sovereign://processes` — verify main + WebKit processes listed
|
||||
with sane CPU%/RSS.
|
||||
4. Open several tabs of a CPU-heavy page (e.g.
|
||||
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
|
||||
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
|
||||
view ranks them by `cpu_busy_percent` and the drill-down shows
|
||||
long-task sources.
|
||||
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
|
||||
identifiable and the drill-down shows which script/timer/endpoint is
|
||||
responsible.
|
||||
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
|
||||
JSON shape matches the contract above.
|
||||
|
||||
## Out of scope (future work)
|
||||
|
||||
- Forcing process-per-tab via `WebKitWebsitePolicies` /
|
||||
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
|
||||
per-tab `/proc` attribution but breaks the existing
|
||||
`related_view` sharing workaround. Tracked separately.
|
||||
- Historical recording (save probe samples to SQLite for trend charts).
|
||||
- Per-tab network throttling / CPU limiting (would need WebKit API
|
||||
support that may not exist).
|
||||
- Killing/suspending a tab's WebProcess specifically (currently only
|
||||
per-tab reload/close, which is safe).
|
||||
224
plans/webkit-data-isolation.md
Normal file
224
plans/webkit-data-isolation.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# WebKit Website Data Isolation Per Nostr Identity
|
||||
|
||||
**Status: IMPLEMENTED (Phase 0 + Phase A, verified end-to-end).**
|
||||
|
||||
## Problem
|
||||
|
||||
When a user logs in, visits a page, logs out, then logs in as a different
|
||||
identity and revisits the same page, the second user sees the first user's
|
||||
data (session cookies, localStorage, cached responses, service workers).
|
||||
|
||||
## Root Cause
|
||||
|
||||
The browser uses the **default** [`WebKitWebContext`](src/main.c:977) and its
|
||||
attached [`WebKitWebsiteDataManager`](src/main.c:993) for the entire process
|
||||
lifetime. That single data manager holds, process-wide:
|
||||
|
||||
- HTTP disk + memory cache
|
||||
- Cookies (the primary leak — session cookies identify you to web pages)
|
||||
- localStorage / sessionStorage / IndexedDB / WebSQL
|
||||
- Service worker registrations + offline app cache
|
||||
- Favicon database ([`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003))
|
||||
|
||||
The logout / identity-switch paths only clear the Nostr signer and do **nothing**
|
||||
to WebKit's data stores or to the already-loaded tabs:
|
||||
|
||||
- [`app_menu_logout_proxy`](src/main.c:186) — frees `g_state.signer`, blanks pubkey, returns.
|
||||
- [`agent_logout`](src/agent_login.c:485) — `app_clear_signer()` + `nostr_bridge_set_signer(NULL, ...)`.
|
||||
- [`agent_switch_identity`](src/agent_login.c:492) — frees old signer, logs in new, no tab reload.
|
||||
|
||||
Two compounding problems:
|
||||
|
||||
1. **Persistent WebKit data** survives the identity switch.
|
||||
2. **Live tabs** keep user A's DOM/localStorage in memory; wiping the data
|
||||
manager alone does not clear already-loaded pages.
|
||||
|
||||
The existing [`plans/per-user-profiles.md`](plans/per-user-profiles.md) isolates
|
||||
the SQLite layer (bookmarks, history, session, Nostr events) but does **not**
|
||||
cover WebKit's own storage — this plan closes that gap.
|
||||
|
||||
## Solution (chosen: per-user data manager + clear-on-switch safety net)
|
||||
|
||||
### Part A — Per-user `WebKitWebsiteDataManager`
|
||||
|
||||
Give each pubkey its own data manager rooted at
|
||||
`~/.sovereign_browser/profiles/<pubkey_hex>/webkit/` so cookies, cache, storage,
|
||||
service workers, and favicons persist per-user and never cross-contaminate.
|
||||
|
||||
WebKitGTK constraint: a `WebKitWebContext` is bound to **one**
|
||||
`WebKitWebsiteDataManager` for its lifetime — you cannot swap the data manager
|
||||
on an existing context. Two viable strategies:
|
||||
|
||||
**Strategy 1 (preferred): per-user `WebKitWebContext`.**
|
||||
Create a fresh `webkit_web_context_new_with_website_data_manager(dm)` for each
|
||||
login. All tabs/webviews are created from this context. On logout/switch,
|
||||
destroy the old context (which tears down its webviews) and build a new one.
|
||||
This is the cleanest isolation and matches how Epiphary/GNOME Web does
|
||||
per-profile isolation.
|
||||
|
||||
**Strategy 2 (fallback): single context, manual clear + per-user dirs.**
|
||||
Keep the default context but on each login call
|
||||
`webkit_website_data_manager_clear(dm, WEBKIT_WEBSITE_DATA_ALL, 0, NULL, NULL, NULL)`
|
||||
then point the cookie/cache/storage dirs at the per-user path. This is fragile
|
||||
because the context caches some state internally and the favicon DB directory
|
||||
is set once at context init.
|
||||
|
||||
Recommend **Strategy 1**. It requires reworking how the context is created and
|
||||
how `tab_manager` / `nostr_bridge` / `tor_scheme` / `nostr_scheme` / `net_services`
|
||||
reference it (they currently call `webkit_web_context_get_default()` or receive
|
||||
the context at init), but it gives true isolation.
|
||||
|
||||
### Part B — Clear-on-switch safety net
|
||||
|
||||
Even with per-user data managers, the **live tabs** (and the agent chat sidebar
|
||||
webview) hold user A's DOM/localStorage in memory. On any identity change:
|
||||
|
||||
1. Close all tabs: `tab_manager_close_all()` ([`tab_manager.c:3727`](src/tab_manager.c)).
|
||||
2. Destroy the agent chat sidebar webview if present.
|
||||
3. (Strategy 1) Destroy the old `WebKitWebContext` + data manager.
|
||||
4. Build the new per-user context + data manager.
|
||||
5. Re-register URI schemes (`sovereign://`, `nostr://`, `tor://`) on the new
|
||||
context — they are per-context.
|
||||
6. Re-init `nostr_bridge`, `nostr_scheme`, `tor_scheme`, `net_services` against
|
||||
the new context.
|
||||
7. Re-init `tab_manager` against the new context (or expose a
|
||||
`tab_manager_set_context()`).
|
||||
8. Restore the new user's session (`session_restore()`) or open the default
|
||||
new-tab URL.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Login complete - pubkey known] --> B[profile_ensure_dir pubkey]
|
||||
B --> C[Build per-user WebKitWebsiteDataManager<br/>rooted at profiles/pubkey/webkit]
|
||||
C --> D[Build per-user WebKitWebContext<br/>new_with_website_data_manager]
|
||||
D --> E[Register sovereign/nostr/tor schemes on new ctx]
|
||||
E --> F[Re-init nostr_bridge, net_services, tab_manager on new ctx]
|
||||
F --> G[session_restore or open new-tab URL]
|
||||
|
||||
H[Logout / switch_identity] --> I[tab_manager_close_all]
|
||||
I --> J[Destroy sidebar webview]
|
||||
J --> K[Destroy old WebKitWebContext + data manager]
|
||||
K --> L{switch or logout?}
|
||||
L -->|switch| A
|
||||
L -->|logout| M[Show login dialog / wait for agent login]
|
||||
M --> A
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Profile helpers for WebKit data dir
|
||||
In [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) add:
|
||||
- `profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz)`
|
||||
→ `~/.sovereign_browser/profiles/<pubkey>/webkit/`
|
||||
- `profile_ensure_webkit_dir(const char *pubkey_hex)` — mkdir -p.
|
||||
|
||||
### 2. New module `src/web_context.c` / `src/web_context.h`
|
||||
Centralize context + data manager construction so login/logout/switch share
|
||||
one code path. Functions:
|
||||
- `web_context_build_for_pubkey(const char *pubkey_hex)` → returns a new
|
||||
`WebKitWebContext*` with a per-user `WebKitWebsiteDataManager` (cookies,
|
||||
cache, local_storage, indexed_db, websql, offline_app_cache, service_worker
|
||||
registrations, itp, hsts all pointed at the per-user webkit dir), favicon
|
||||
DB enabled, TLS errors ignored, security manager configured
|
||||
(sovereign/tor/file secure+local).
|
||||
- `web_context_teardown(WebKitWebContext *ctx)` — unrefs context + data
|
||||
manager, frees per-user state.
|
||||
- Holds the current context pointer (`g_web_ctx`) so the rest of the codebase
|
||||
can fetch it without `webkit_web_context_get_default()`.
|
||||
|
||||
### 3. Refactor scheme/bridge/service init to be re-runnable
|
||||
Currently these are one-shot inits tied to the default context. Make them
|
||||
accept a `WebKitWebContext*` and be safe to call again on a new context:
|
||||
- [`nostr_bridge_register`](src/nostr_bridge.c) — already takes `ctx`; ensure
|
||||
it can be called twice (track prior registration, disconnect old handlers).
|
||||
- [`nostr_scheme_register`](src/nostr_scheme.c) — make it take `ctx` (currently
|
||||
uses default) and idempotent.
|
||||
- [`tor_scheme_register`](src/tor_scheme.c) — same.
|
||||
- [`net_services_init`](src/net_services.c) — currently calls
|
||||
`webkit_web_context_get_default()` internally ([`net_services.c:69`](src/net_services.c));
|
||||
pass `ctx` in instead.
|
||||
|
||||
### 4. Refactor `tab_manager` to support context swap
|
||||
[`tab_manager_init`](src/tab_manager.h:46) currently stores the context once.
|
||||
Add:
|
||||
- `tab_manager_set_context(WebKitWebContext *ctx)` — updates the stored
|
||||
context so subsequent `tab_manager_new_tab` calls use the new context.
|
||||
Existing tabs are already closed (Part B step 1) before this is called.
|
||||
- Or simpler: tear down and re-init `tab_manager` on each switch. Pick whichever
|
||||
is less invasive given the static globals in [`tab_manager.c`](src/tab_manager.c).
|
||||
|
||||
### 5. Wire the login/logout/switch flows
|
||||
- [`app_menu_logout_proxy`](src/main.c:186): after clearing the signer, run the
|
||||
teardown sequence (close all tabs, destroy sidebar, destroy context) then
|
||||
re-show the login dialog. On successful login, run the build sequence.
|
||||
- [`agent_logout`](src/agent_login.c:485): same teardown, then leave the
|
||||
browser in a logged-out state (no context, or a minimal ephemeral context
|
||||
showing a "logged out" page).
|
||||
- [`agent_switch_identity`](src/agent_login.c:492): teardown old, build new
|
||||
with the new pubkey, restore session.
|
||||
- Initial startup in [`main.c`](src/main.c:974): replace
|
||||
`webkit_web_context_get_default()` with `web_context_build_for_pubkey()`
|
||||
after login completes (this means deferring context creation until after
|
||||
the login dialog — reordering the startup sequence).
|
||||
|
||||
### 6. Favicon database per user
|
||||
[`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003)
|
||||
currently uses the default shared location. In `web_context_build_for_pubkey`,
|
||||
pass the per-user webkit dir (or a `favicons/` subdir) so favicons don't leak
|
||||
between users.
|
||||
|
||||
### 7. Read-only / no-login mode
|
||||
When running with `--no-login` or read-only, there is no pubkey to key the
|
||||
data dir on. Use an ephemeral data manager
|
||||
(`webkit_website_data_manager_new_ephemeral()`) so no data persists at all,
|
||||
or a shared `~/.sovereign_browser/webkit-anon/` dir. Decide and document.
|
||||
|
||||
### 8. Migration
|
||||
Existing users have data in WebKit's default location
|
||||
(`~/.cache/sovereign_browser/` or similar). On first login with the new
|
||||
system, optionally copy the default WebKit data dir into
|
||||
`profiles/<pubkey>/webkit/` so they keep their cookies/cache. Low priority —
|
||||
can ship without migration and just start fresh per user.
|
||||
|
||||
### 9. Tests
|
||||
- Manual: log in as user A, visit a site that sets a cookie/localStorage,
|
||||
log out, log in as user B, visit the same site, confirm user A's data is
|
||||
gone and user B gets a fresh session.
|
||||
- Add a test page under [`tests/local-site/`](tests/local-site) that writes
|
||||
a visible marker to localStorage + a cookie, so this is reproducible.
|
||||
- Verify `sovereign://`, `nostr://`, `tor://` schemes still work after a
|
||||
switch (they are re-registered on the new context).
|
||||
- Verify the agent chat sidebar works after a switch (its webview is rebuilt
|
||||
on the new context).
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- New: `src/web_context.c`, `src/web_context.h`
|
||||
- [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) — webkit dir helpers
|
||||
- [`src/main.c`](src/main.c) — startup ordering, logout proxy, use `web_context_*`
|
||||
- [`src/agent_login.c`](src/agent_login.c) — `agent_logout`, `agent_switch_identity`
|
||||
- [`src/tab_manager.c`](src/tab_manager.c) / [`src/tab_manager.h`](src/tab_manager.h) — context swap support, sidebar teardown
|
||||
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — re-runnable registration
|
||||
- [`src/nostr_scheme.c`](src/nostr_scheme.c) — accept ctx, idempotent
|
||||
- [`src/tor_scheme.c`](src/tor_scheme.c) — accept ctx, idempotent
|
||||
- [`src/net_services.c`](src/net_services.c) — accept ctx instead of default
|
||||
- [`Makefile`](Makefile) — add `web_context.o`
|
||||
- New test page: `tests/local-site/identity-leak-test.html`
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **High risk** — changes how the `WebKitWebContext` is created and tears down,
|
||||
which touches every subsystem that references the context.
|
||||
- **Startup reordering** — context creation must move to *after* login, which
|
||||
changes the long-standing flow in [`main.c`](src/main.c).
|
||||
- **Re-runnable scheme registration** — WebKit may not support unregistering
|
||||
scheme handlers cleanly; verify that building a fresh context and
|
||||
re-registering works without leaking the old context.
|
||||
- **Sidebar webview** — the agent chat sidebar ([`tab_manager_toggle_sidebar`](src/tab_manager.h:198))
|
||||
keeps a long-lived webview; must be destroyed on context swap or it will
|
||||
hold a ref to the dead context.
|
||||
- **Mitigation**: implement Part B (clear-on-switch) first as a standalone
|
||||
change — it alone fixes the leak even without per-user dirs. Ship that,
|
||||
then layer Part A (per-user data managers) on top.
|
||||
Binary file not shown.
@@ -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. */
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "settings.h"
|
||||
#include "search.h"
|
||||
#include "nostr_url.h"
|
||||
#include "process_info.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -4275,6 +4276,30 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
return agent_fs_tools_dispatch(tool_name, params);
|
||||
}
|
||||
|
||||
/* ── Process / per-tab diagnostics (work before login) ────────── *
|
||||
* These read /proc and the in-memory probe store; they don't touch
|
||||
* Nostr or page content, so they're safe before login. */
|
||||
if (strcmp(tool_name, "processes.list") == 0) {
|
||||
cJSON *arr = process_info_get_processes_json();
|
||||
return make_success(arr);
|
||||
}
|
||||
if (strcmp(tool_name, "processes.tabs") == 0) {
|
||||
cJSON *arr = process_info_get_tabs_json();
|
||||
return make_success(arr);
|
||||
}
|
||||
if (strcmp(tool_name, "processes.tab_probe") == 0) {
|
||||
int idx = get_int_param(params, "tab_index", -1);
|
||||
if (idx < 0) {
|
||||
return make_error("MISSING_PARAM",
|
||||
"Provide 'tab_index' (from processes.tabs)");
|
||||
}
|
||||
cJSON *obj = process_info_get_tab_probe_json(idx);
|
||||
if (obj == NULL) {
|
||||
return make_error("BAD_INDEX", "Tab index out of range");
|
||||
}
|
||||
return make_success(obj);
|
||||
}
|
||||
|
||||
/* Check login requirement for known tools. */
|
||||
gboolean requires_login = TRUE;
|
||||
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
|
||||
|
||||
1112
src/bookmarks.c
1112
src/bookmarks.c
File diff suppressed because it is too large
Load Diff
133
src/bookmarks.h
133
src/bookmarks.h
@@ -1,9 +1,22 @@
|
||||
/*
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
|
||||
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
|
||||
*
|
||||
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
|
||||
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
|
||||
* and stored in the event's content field as a JSON tag array.
|
||||
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
|
||||
*
|
||||
* Privacy design:
|
||||
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
|
||||
* 64-hex-char identifier. Relays learn nothing about folder names or
|
||||
* structure (not even the folder count). Determinism preserves NIP-33
|
||||
* replaceability: re-publishing the same path replaces the prior event.
|
||||
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
|
||||
* both use a random IV/nonce per call, which would break replaceability.
|
||||
* - The real path and the bookmark list live inside the NIP-44 encrypted
|
||||
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
|
||||
* - The HMAC key is derived from the user's Nostr privkey:
|
||||
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
|
||||
* It is per-user, never published, and automatically available on every
|
||||
* device the user logs in on.
|
||||
*
|
||||
* The bookmarks sync across all devices the user logs in on via the
|
||||
* bootstrap relays.
|
||||
@@ -22,10 +35,15 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define BOOKMARKS_MAX_PER_DIR 500
|
||||
#define BOOKMARKS_DIR_NAME_MAX 128
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
#define BOOKMARKS_MAX_PER_NODE 500
|
||||
#define BOOKMARKS_NAME_MAX 128
|
||||
#define BOOKMARKS_PATH_MAX 1024
|
||||
#define BOOKMARKS_URL_MAX 2048
|
||||
#define BOOKMARKS_TITLE_MAX 256
|
||||
|
||||
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
|
||||
* per application and can be rotated independently if ever needed. */
|
||||
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
|
||||
|
||||
typedef struct {
|
||||
char *url;
|
||||
@@ -33,70 +51,101 @@ typedef struct {
|
||||
long added; /* unix timestamp */
|
||||
} bookmark_t;
|
||||
|
||||
typedef struct {
|
||||
char name[BOOKMARKS_DIR_NAME_MAX];
|
||||
bookmark_t *bookmarks;
|
||||
int count;
|
||||
} bookmark_dir_t;
|
||||
/* A node in the bookmark tree. The root node has name="" and path="".
|
||||
* Each node corresponds to a folder path; bookmarks live at any node. */
|
||||
typedef struct bookmark_node {
|
||||
char *name; /* last path segment, "" for root */
|
||||
char *path; /* full path, "" for root */
|
||||
bookmark_t *bookmarks;
|
||||
int bookmark_count;
|
||||
struct bookmark_node *children; /* array of child nodes */
|
||||
int child_count;
|
||||
int child_cap;
|
||||
} bookmark_node_t;
|
||||
|
||||
/*
|
||||
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
|
||||
* database and decrypts them using the signer.
|
||||
*
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
|
||||
* pubkey_hex — the user's hex pubkey (64 chars)
|
||||
* signer — the user's nostr_signer_t (NULL for read-only/no-login).
|
||||
* The signer holds the private key; d tags are derived via
|
||||
* nostr_signer_derive_hmac, so the privkey never lives in
|
||||
* browser memory.
|
||||
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
|
||||
int bookmarks_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark list. Call at shutdown. */
|
||||
/* Free the in-memory bookmark tree. Call at shutdown. */
|
||||
void bookmarks_cleanup(void);
|
||||
|
||||
/* ── Read access ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Get the list of directories (read-only). Returns a pointer to the
|
||||
* internal array (valid until the next bookmarks operation). */
|
||||
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
|
||||
/* Get the root node of the bookmark tree (read-only). Returns NULL if
|
||||
* bookmarks_init has not been called. The returned pointer is valid until
|
||||
* the next bookmarks mutation. */
|
||||
const bookmark_node_t *bookmarks_get_root(void);
|
||||
|
||||
/* Get a specific directory by name. Returns NULL if not found. */
|
||||
const bookmark_dir_t *bookmarks_get_dir(const char *name);
|
||||
/* Find a node by path. Returns NULL if not found. */
|
||||
const bookmark_node_t *bookmarks_find(const char *path);
|
||||
|
||||
/* ── Write access (requires signer) ────────────────────────────────── */
|
||||
/* ── Write access (requires signer + privkey) ──────────────────────── */
|
||||
|
||||
/* Add a bookmark to a directory. If the directory doesn't exist, it's
|
||||
* created. Updates the in-memory list, re-encrypts, publishes a new
|
||||
* kind 30003 event for that directory, and stores in SQLite.
|
||||
/* Add a bookmark to a folder path. If the folder (or any intermediate
|
||||
* folder) doesn't exist, it is created. Updates the in-memory tree,
|
||||
* re-encrypts, publishes a new kind 30003 event for that path, and stores
|
||||
* in SQLite.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_add(const char *dir, const char *url, const char *title);
|
||||
int bookmarks_add(const char *path, const char *url, const char *title);
|
||||
|
||||
/* Remove a bookmark by URL from a directory.
|
||||
/* Remove a bookmark by URL from a folder path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *dir, const char *url);
|
||||
int bookmarks_remove(const char *path, const char *url);
|
||||
|
||||
/* Move a bookmark from one directory to another.
|
||||
/* Rename a bookmark's title (the user-editable label) in place. The
|
||||
* bookmark is located by (path, url); only its title changes. Re-encrypts
|
||||
* and publishes a new kind 30003 event for that path.
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_rename(const char *path, const char *url, const char *new_title);
|
||||
|
||||
/* Move a bookmark from one folder to another.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_move(const char *from_dir, const char *url,
|
||||
const char *to_dir);
|
||||
int bookmarks_move(const char *from_path, const char *url,
|
||||
const char *to_path);
|
||||
|
||||
/* Create a new empty directory. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if directory already exists / error. */
|
||||
int bookmarks_create_dir(const char *name);
|
||||
/* Create a new empty folder at the given path. Parent folders are created
|
||||
* if missing. Publishes an empty kind 30003 event.
|
||||
* Returns 0 on success, -1 if the folder already exists / error. */
|
||||
int bookmarks_create_dir(const char *path);
|
||||
|
||||
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
|
||||
* to "General" before deletion. Publishes a kind 5 deletion event.
|
||||
/* Rename a folder. Re-publishes the renamed node and every descendant with
|
||||
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
|
||||
* old d tags.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *name, int move_to_general);
|
||||
int bookmarks_rename_dir(const char *old_path, const char *new_path);
|
||||
|
||||
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
|
||||
* its descendants' bookmarks) are moved to "General" before deletion.
|
||||
* Emits kind 5 deletion events for every d tag in the subtree.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int bookmarks_delete_dir(const char *path, int move_to_general);
|
||||
|
||||
/* ── Change notification ───────────────────────────────────────────── */
|
||||
|
||||
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
|
||||
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
|
||||
* refresh every open tab. */
|
||||
typedef void (*bookmarks_changed_cb)(void *user_data);
|
||||
|
||||
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
|
||||
|
||||
/* ── Internal (used by relay fetch) ────────────────────────────────── */
|
||||
|
||||
/* Load bookmarks for a directory from a decrypted kind 30003 event's
|
||||
* content JSON. Called by bookmarks_init and after relay fetch. */
|
||||
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
|
||||
|
||||
/* Store a raw kind 30003 event in the database and decrypt its content
|
||||
* into the in-memory list. Called by the relay fetch after login. */
|
||||
* into the in-memory tree. Called by the relay fetch after login. */
|
||||
int bookmarks_store_and_load_event(const void *event_cjson);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -182,6 +182,32 @@ char *fips_control_show_peers(int fd, char *error, size_t error_size) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Generic single-command "show_*" that returns the data field as
|
||||
* compact JSON. Used by show_tree and show_identity_cache, which have
|
||||
* the same response shape as show_peers. */
|
||||
static char *fips_control_show_generic(int fd, const char *command,
|
||||
char *error, size_t error_size) {
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", command);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return NULL;
|
||||
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
|
||||
char *result = printed ? g_strdup(printed) : NULL;
|
||||
cJSON_free(printed);
|
||||
cJSON_Delete(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_tree", error, error_size);
|
||||
}
|
||||
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_identity_cache", error, error_size);
|
||||
}
|
||||
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size) {
|
||||
@@ -202,6 +228,23 @@ int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
return 0;
|
||||
}
|
||||
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size) {
|
||||
if (!npub || !npub[0]) {
|
||||
set_error(error, error_size, "%s", "FIPS peer npub is required");
|
||||
return -1;
|
||||
}
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "disconnect");
|
||||
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
|
||||
cJSON_AddStringToObject(params, "npub", npub);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return -1;
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void fips_control_close(int *fd) {
|
||||
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
|
||||
}
|
||||
|
||||
@@ -26,9 +26,14 @@ int fips_control_show_status(int fd, fips_status_t *status,
|
||||
char *error, size_t error_size);
|
||||
/* Returns the response data as compact JSON. Caller frees with g_free(). */
|
||||
char *fips_control_show_peers(int fd, char *error, size_t error_size);
|
||||
/* show_tree / show_identity_cache — same contract as show_peers. */
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size);
|
||||
const char *transport,
|
||||
char *error, size_t error_size);
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
void fips_control_close(int *fd);
|
||||
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms);
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ void history_add_titled(const char *url, const char *title) {
|
||||
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
|
||||
strncmp(url, "sovereign://bookmarks/rename", 28) == 0 ||
|
||||
strncmp(url, "sovereign://qr", 14) == 0 ||
|
||||
strncmp(url, "sovereign://nostr/", 18) == 0) {
|
||||
return;
|
||||
|
||||
@@ -84,6 +84,12 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
if (signer && identity->nsigner_index >= 0) {
|
||||
nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index);
|
||||
}
|
||||
/* n_signer's serial and TCP transports require a kind-27235 auth
|
||||
* envelope on every request. Install the default caller identity
|
||||
* so the re-created signer can talk to the device. */
|
||||
if (signer) {
|
||||
key_store_nsigner_set_default_auth(signer);
|
||||
}
|
||||
return signer;
|
||||
#else
|
||||
return NULL;
|
||||
@@ -95,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── n_signer default caller auth ─────────────────────────────────── *
|
||||
* n_signer's serial and TCP transports require a kind-27235 auth envelope
|
||||
* on every request. Without it the device rejects the call with an
|
||||
* auth-related RPC error (codes 2010-2017) that the client maps to
|
||||
* NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as
|
||||
* "device requires approval" even though no on-device approval is actually
|
||||
* needed.
|
||||
*
|
||||
* This installs the same fixed, well-known caller identity used by n_signer's
|
||||
* own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side
|
||||
* authentication credential only — it does NOT grant access to the device's
|
||||
* mnemonic/keys; the device's own policy still decides which operations are
|
||||
* allowed. No-op for non-nsigner backends.
|
||||
*/
|
||||
int key_store_nsigner_set_default_auth(nostr_signer_t *signer) {
|
||||
if (signer == NULL) {
|
||||
return -1;
|
||||
}
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
/* Match the webserial demo's caller privkey: bytes 1..32. */
|
||||
static const unsigned char default_caller_priv[32] = {
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
|
||||
};
|
||||
return nostr_signer_nsigner_set_auth(signer, default_caller_priv,
|
||||
"sovereign_browser");
|
||||
#else
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Legacy file cleanup ──────────────────────────────────────────── */
|
||||
|
||||
int key_store_delete_legacy_file(void) {
|
||||
|
||||
@@ -87,6 +87,24 @@ int key_store_save_profile_identity(const char *pubkey_hex,
|
||||
int key_store_load_profile_identity(const char *pubkey_hex,
|
||||
key_store_method_t *method_out);
|
||||
|
||||
/*
|
||||
* Set a default caller auth identity on an n_signer remote signer.
|
||||
*
|
||||
* n_signer's serial and TCP transports require a kind-27235 auth envelope
|
||||
* on every request (the device rejects unauthenticated calls with an
|
||||
* auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE).
|
||||
* This installs a fixed, well-known caller identity (the same one used by
|
||||
* n_signer's own webserial demo) so the browser can talk to a USB-attached
|
||||
* n_signer without the user having to configure caller credentials.
|
||||
*
|
||||
* Safe to call on any signer; no-op for non-nsigner backends. Call after
|
||||
* the signer is created and before the first verb (e.g. before
|
||||
* nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key).
|
||||
*
|
||||
* Returns 0 on success, non-zero on error (e.g. not an nsigner signer).
|
||||
*/
|
||||
int key_store_nsigner_set_default_auth(nostr_signer_t *signer);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "login_dialog.h"
|
||||
#include "key_store.h"
|
||||
#include "agent_login.h"
|
||||
#include "version.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -111,6 +112,7 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
|
||||
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
|
||||
static void on_index_changed(GtkWidget *spin, gpointer user_data);
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
|
||||
#endif
|
||||
@@ -512,6 +514,13 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* n_signer's serial/TCP transports require a kind-27235 auth envelope
|
||||
* on every request. Install the default caller identity (matching
|
||||
* n_signer's webserial demo) before issuing any verbs. Without this
|
||||
* the device rejects the call with an auth error that surfaces as
|
||||
* "code -310". */
|
||||
key_store_nsigner_set_default_auth(signer);
|
||||
|
||||
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
|
||||
if (rc_idx != NOSTR_SUCCESS) {
|
||||
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
||||
@@ -524,19 +533,34 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
|
||||
char pubkey_hex[65];
|
||||
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc_pk != NOSTR_SUCCESS) {
|
||||
char errmsg[256];
|
||||
char errmsg[320];
|
||||
const char *desc = "unknown error";
|
||||
switch (rc_pk) {
|
||||
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
|
||||
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
|
||||
case -2002: desc = "index not in signer's whitelist"; break;
|
||||
case -3: desc = "crypto operation failed"; break;
|
||||
case NOSTR_ERROR_NIP46_AUTH_CHALLENGE:
|
||||
/* Device RPC codes 2010-2017: auth-related rejection.
|
||||
* Most commonly this means the kind-27235 caller auth
|
||||
* envelope was missing/rejected (the default caller
|
||||
* identity is installed automatically, but the device's
|
||||
* policy may still deny it). It can also mean the device
|
||||
* is requesting on-device approval (button press / TUI
|
||||
* confirm) for this operation. */
|
||||
desc = "auth rejected by n_signer (code -310). The caller "
|
||||
"auth envelope was missing or denied, or the device "
|
||||
"is requesting on-device approval. Confirm any "
|
||||
"prompt on the n_signer hardware, then click Sign "
|
||||
"In again";
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
snprintf(errmsg, sizeof(errmsg),
|
||||
"n_signer error: %s (code %d). Try a different key index.",
|
||||
desc, rc_pk);
|
||||
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
|
||||
nostr_signer_free(signer);
|
||||
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
||||
return;
|
||||
}
|
||||
@@ -1016,17 +1040,24 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
|
||||
g_signal_connect(transport_combo, "changed",
|
||||
G_CALLBACK(on_transport_changed), box);
|
||||
|
||||
/* Nostr index spinner. */
|
||||
/* Nostr index spinner. The label shows the derived BIP-32 path
|
||||
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
|
||||
* spin button value, so they can see which key they are selecting. */
|
||||
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
|
||||
|
||||
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
|
||||
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
|
||||
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
|
||||
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
|
||||
|
||||
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
|
||||
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
|
||||
|
||||
/* Update the path label whenever the index value changes. */
|
||||
g_signal_connect(index_spin, "value-changed",
|
||||
G_CALLBACK(on_index_changed), index_label);
|
||||
|
||||
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
|
||||
gtk_widget_set_sensitive(hint, FALSE);
|
||||
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
||||
@@ -1040,9 +1071,23 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
|
||||
g_object_set_data(G_OBJECT(box), "service-box", service_box);
|
||||
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
|
||||
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
|
||||
g_object_set_data(G_OBJECT(box), "index-label", index_label);
|
||||
return box;
|
||||
}
|
||||
|
||||
/* Update the key-index path label when the spin button value changes.
|
||||
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
|
||||
* current N value substituted in. */
|
||||
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
|
||||
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
|
||||
GtkWidget *label = GTK_WIDGET(user_data);
|
||||
int idx = gtk_spin_button_get_value_as_int(sb);
|
||||
char text[96];
|
||||
snprintf(text, sizeof(text),
|
||||
"Key Index (m/44'/1237'/%d'/0/0):", idx);
|
||||
gtk_label_set_text(GTK_LABEL(label), text);
|
||||
}
|
||||
|
||||
/* ── Serial device enumeration callback ───────────────────────── */
|
||||
|
||||
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
||||
@@ -1092,7 +1137,7 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
|
||||
/* Create dialog without auto-responding buttons — we add custom
|
||||
* buttons so we control whether the dialog closes. */
|
||||
GtkWidget *dialog = gtk_dialog_new();
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser — Sign In");
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser " SB_VERSION);
|
||||
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
||||
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
|
||||
|
||||
407
src/main.c
407
src/main.c
@@ -55,6 +55,8 @@
|
||||
#include "agent_conversations.h"
|
||||
#include "agent_skills.h"
|
||||
#include "net_services.h"
|
||||
#include "webkit_data.h"
|
||||
#include "web_context.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -66,21 +68,33 @@
|
||||
typedef struct {
|
||||
nostr_signer_t *signer; /* NULL for read-only mode */
|
||||
char pubkey_hex[65];
|
||||
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
|
||||
key_store_method_t method;
|
||||
gboolean readonly; /* TRUE if no signing available */
|
||||
} app_state_t;
|
||||
|
||||
static app_state_t g_state = {0};
|
||||
|
||||
/* Forward declaration — defined before main(). */
|
||||
/* Forward declarations — defined later in this file. */
|
||||
static int switch_to_user_db(const char *pubkey_hex);
|
||||
static int do_login(GtkWindow *parent);
|
||||
static WebKitWebContext *build_context_for_current_user(void);
|
||||
static char g_current_profile_db[512];
|
||||
static GtkWindow *g_window = NULL;
|
||||
static gboolean g_logged_in = FALSE;
|
||||
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
|
||||
|
||||
/* TRUE once main() has finished initial startup (tab_manager_init has
|
||||
* run). Used by app_set_signer() to distinguish a first-time login
|
||||
* (context built later by main()) from a runtime identity switch
|
||||
* (context was torn down by identity_teardown_web_state and must be
|
||||
* rebuilt here). */
|
||||
static gboolean g_post_startup = FALSE;
|
||||
|
||||
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
|
||||
|
||||
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex,
|
||||
key_store_method_t method, gboolean readonly) {
|
||||
g_state.signer = signer;
|
||||
g_state.method = method;
|
||||
@@ -89,6 +103,12 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
}
|
||||
if (privkey_hex && privkey_hex[0]) {
|
||||
strncpy(g_state.privkey_hex, privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
} else {
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
}
|
||||
g_logged_in = TRUE;
|
||||
|
||||
/* Update modules that hold a signer reference. */
|
||||
@@ -104,6 +124,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* If this is a RUNTIME identity switch (past startup) and the
|
||||
* previous context was torn down by identity_teardown_web_state(),
|
||||
* rebuild a fresh per-user context for the new identity now. On the
|
||||
* first login (before main() calls tab_manager_init), g_post_startup
|
||||
* is FALSE and web_context_get() is NULL, so main() builds the
|
||||
* context after login — we skip here to avoid a double-build. */
|
||||
if (g_post_startup && web_context_get() == NULL) {
|
||||
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user so they don't stare at
|
||||
* an empty window after the teardown closed all tabs. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to rebuild web context\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_clear_signer(void) {
|
||||
@@ -112,6 +149,7 @@ void app_clear_signer(void) {
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
@@ -131,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; }
|
||||
* menu builder.
|
||||
*/
|
||||
|
||||
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
|
||||
* Called on logout and identity switch. Closes all tabs, destroys the
|
||||
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
|
||||
* with the previous user's sovereign://agents/chat session), and wipes
|
||||
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
|
||||
* service workers, favicons) from the shared WebKitWebsiteDataManager.
|
||||
*
|
||||
* Without this, the next user inherits the previous user's web session:
|
||||
* cookies identify you to web pages, localStorage holds per-site state,
|
||||
* and the live tabs keep the previous user's DOM in memory. See
|
||||
* plans/webkit-data-isolation.md for the full rationale.
|
||||
*
|
||||
* This must run on the GTK main thread. It pumps a nested main loop
|
||||
* briefly while webkit_website_data_manager_clear() completes.
|
||||
*/
|
||||
void identity_teardown_web_state(void) {
|
||||
/* 1. Close all tabs. This destroys the webviews and drops their
|
||||
* in-memory DOM/localStorage. Must happen before the context
|
||||
* teardown so no webviews hold dangling references to the old
|
||||
* context. Suppress the normal "last tab closed → quit the app"
|
||||
* behavior so the browser stays alive for the next user; the
|
||||
* caller opens a fresh tab after the switch. */
|
||||
tab_manager_set_suppress_quit_on_last_tab(TRUE);
|
||||
tab_manager_close_all();
|
||||
tab_manager_set_suppress_quit_on_last_tab(FALSE);
|
||||
|
||||
/* 2. Destroy the agent chat sidebar webviews in every window. The
|
||||
* sidebar webview is outside the notebook so tab_manager_close_all
|
||||
* does not touch it; it would otherwise keep a reference to the
|
||||
* old context (and the previous user's chat session) alive. */
|
||||
tab_manager_destroy_sidebar_webviews();
|
||||
|
||||
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
|
||||
* context and its per-user WebKitWebsiteDataManager. With Phase A
|
||||
* isolation, each user has their own data manager rooted at
|
||||
* profiles/<pubkey>/webkit/, so tearing down the context is what
|
||||
* actually isolates the users — the next build creates a fresh
|
||||
* data manager for the new user. The Phase 0 webkit_data_clear_all
|
||||
* wipe is no longer needed for isolation (the data manager is
|
||||
* per-user), but we keep it as a defense-in-depth safety net in
|
||||
* case any process-global WebKit state survived the context
|
||||
* teardown. */
|
||||
WebKitWebContext *ctx = web_context_get();
|
||||
if (ctx != NULL) {
|
||||
/* Defense-in-depth: clear the data manager before tearing down
|
||||
* the context. This catches any process-global caches that
|
||||
* outlive the context. */
|
||||
int rc = webkit_data_clear_all(ctx, FALSE);
|
||||
if (rc != 0) {
|
||||
g_printerr("[identity] webkit_data_clear_all returned %d "
|
||||
"(continuing with context teardown)\n", rc);
|
||||
}
|
||||
web_context_teardown();
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
@@ -138,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
login_result_t result;
|
||||
if (login_dialog_run(window, &result) == 0) {
|
||||
/* Tear down the previous user's web state BEFORE installing the
|
||||
* new signer, so the new user starts with a clean web session. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
}
|
||||
@@ -157,6 +255,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* Build a fresh per-user WebKitWebContext for the new identity
|
||||
* (with its own per-user data manager) and re-register the
|
||||
* sovereign://, nostr://, tor:// schemes on it. The old context
|
||||
* was torn down by identity_teardown_web_state() above. */
|
||||
if (build_context_for_current_user() == NULL) {
|
||||
g_printerr("[identity] Failed to build context for new user — "
|
||||
"browser will have no web views\n");
|
||||
}
|
||||
|
||||
/* Open a fresh tab for the new user so they don't stare at an
|
||||
* empty window after the teardown closed all the previous user's
|
||||
* tabs. Uses the new user's per-user new_tab_url setting. */
|
||||
const char *url = settings_get()->new_tab_url;
|
||||
tab_manager_new_tab(url);
|
||||
|
||||
g_print("[identity] switched: method=%d pubkey=%s\n",
|
||||
g_state.method, g_state.pubkey_hex);
|
||||
}
|
||||
@@ -176,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
|
||||
/* Tear down the current user's web state (closes all tabs, destroys
|
||||
* the sidebar webviews, wipes cookies/cache/localStorage/service
|
||||
* workers/favicons) BEFORE clearing the signer, so the next login
|
||||
* starts with a clean web session. See plans/webkit-data-isolation.md. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
agent_conversations_set_signer(NULL, NULL);
|
||||
|
||||
/* Reset the per-user db tracking so the next login re-opens the
|
||||
* new user's browser.db rather than assuming we're already on it. */
|
||||
g_current_profile_db[0] = '\0';
|
||||
|
||||
g_print("[identity] logged out\n");
|
||||
|
||||
/* Re-show the login dialog so the user (or agent) can log in as a
|
||||
* different identity. The dialog runs a nested main loop, so the
|
||||
* agent server stays live and an agent can log in via MCP while the
|
||||
* dialog is showing. On success, do_login() installs the new signer
|
||||
* and switch_to_user_db() opens the new user's profile. */
|
||||
if (window != NULL) {
|
||||
if (do_login(window) == 0) {
|
||||
/* Build a fresh per-user WebKitWebContext for the new
|
||||
* identity. The old context was torn down above. For
|
||||
* --no-login mode this builds an ephemeral context. */
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to build context after re-login\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
|
||||
@@ -272,45 +421,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
|
||||
* sovereign:// URI scheme bridge in nostr_bridge.c.
|
||||
*/
|
||||
|
||||
/* Hamburger-menu items for sovereign:// internal pages always open in
|
||||
* a new tab so the user's current page is preserved. */
|
||||
void on_menu_settings(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
|
||||
} else {
|
||||
/* No active tab — open a new one pointed at the settings page. */
|
||||
tab_manager_new_tab("sovereign://settings");
|
||||
}
|
||||
tab_manager_new_tab("sovereign://settings");
|
||||
}
|
||||
|
||||
void on_menu_profile(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
|
||||
} else {
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
|
||||
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
|
||||
* the sovereign://agents internal page, which renders the agent provider
|
||||
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
|
||||
* internal page in a new tab. It renders the agent provider
|
||||
* configuration UI and a link to open the agent chat. */
|
||||
void on_menu_agent(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://agents");
|
||||
}
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://agents");
|
||||
} else {
|
||||
/* No active tab — open a new one pointed at the agent page. */
|
||||
tab_manager_new_tab("sovereign://agents");
|
||||
}
|
||||
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
|
||||
* internal page in a new tab. It renders the FIPS mesh network
|
||||
* status + management UI. */
|
||||
void on_menu_fips(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://fips");
|
||||
}
|
||||
|
||||
/* The hamburger-menu "Processes…" item opens the sovereign://processes
|
||||
* internal page in a new tab. It renders the process list + per-tab
|
||||
* performance diagnostics UI. */
|
||||
void on_menu_processes(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_new_tab("sovereign://processes");
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- *
|
||||
@@ -438,6 +587,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
on_menu_settings(NULL, NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_OPEN_PROCESSES:
|
||||
on_menu_processes(NULL, NULL);
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_NEW_IDENTITY:
|
||||
app_menu_switch_identity_proxy(NULL, g_window);
|
||||
return TRUE;
|
||||
@@ -490,7 +643,83 @@ static void agent_login_callback(void) {
|
||||
g_print("[login] Agent login detected.\n");
|
||||
}
|
||||
|
||||
/* ---- Window destroy ------------------------------------------------- */
|
||||
/* ---- Window close / destroy ----------------------------------------- *
|
||||
* The main window uses a delete-event handler to intercept the window
|
||||
* manager's close request BEFORE the window is destroyed. This lets us
|
||||
* close the main window's tabs cleanly (updating g_tab_count) and keep
|
||||
* the app running if auxiliary windows still have tabs. The app only
|
||||
* quits when the last window is closed (no tabs remain anywhere).
|
||||
*
|
||||
* delete-event: runs first. Closes all main-window tabs. If aux windows
|
||||
* still have tabs, returns TRUE to suppress destroy and hides the
|
||||
* main window. If no tabs remain, returns FALSE to let destroy
|
||||
* proceed → on_window_destroy → gtk_main_quit().
|
||||
* destroy: runs only when the app is truly shutting down. Performs
|
||||
* session save, net_services_shutdown, agent_server_stop, etc.
|
||||
*/
|
||||
|
||||
/* Guard flag: set while on_window_delete_event is closing the main
|
||||
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
|
||||
* calls gtk_window_close(g_window) after the last tab is closed, which
|
||||
* would re-emit delete-event. */
|
||||
static gboolean g_main_window_closing = FALSE;
|
||||
|
||||
static gboolean on_window_delete_event(GtkWidget *widget,
|
||||
GdkEvent *event,
|
||||
gpointer data) {
|
||||
(void)event;
|
||||
(void)data;
|
||||
|
||||
/* If we're already in the process of closing (re-entrant call from
|
||||
* tab_manager_close_tab → gtk_window_close), let the destroy
|
||||
* proceed. */
|
||||
if (g_main_window_closing) {
|
||||
return FALSE;
|
||||
}
|
||||
g_main_window_closing = TRUE;
|
||||
|
||||
/* Close all tabs that belong to the main window's notebook. We
|
||||
* identify main-window tabs by checking gtk_notebook_page_num on
|
||||
* the main notebook. tab_manager_close_tab handles removing from
|
||||
* the notebook and the g_tabs array. Re-scan each iteration because
|
||||
* closing shifts indices. */
|
||||
for (;;) {
|
||||
gboolean found = FALSE;
|
||||
GtkWidget *main_nb = tab_manager_get_main_notebook();
|
||||
if (main_nb == NULL) break;
|
||||
/* Close from the highest index down so removal doesn't shift
|
||||
* unprocessed indices. Find the highest-index tab in the main
|
||||
* notebook and close it. */
|
||||
for (int i = tab_manager_count() - 1; i >= 0; i--) {
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
if (tab == NULL || tab->page == NULL) continue;
|
||||
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
|
||||
tab->page) >= 0) {
|
||||
tab_manager_close_tab(i);
|
||||
found = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
|
||||
/* If auxiliary windows still have tabs, keep the app running.
|
||||
* Suppress the default destroy by returning TRUE, and hide the
|
||||
* main window. The aux windows continue independently. */
|
||||
if (tab_manager_count() > 0) {
|
||||
g_print("[windows] Main window closed but %d tab(s) remain in "
|
||||
"other window(s) — keeping app alive.\n",
|
||||
tab_manager_count());
|
||||
gtk_widget_hide(widget);
|
||||
g_main_window_closing = FALSE; /* allow re-opening later */
|
||||
return TRUE; /* suppress destroy */
|
||||
}
|
||||
|
||||
/* No tabs left anywhere — let the destroy proceed, which triggers
|
||||
* on_window_destroy and quits the app. Keep the guard set so any
|
||||
* re-entrant delete-event from the destroy path doesn't re-enter. */
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
(void)widget;
|
||||
@@ -558,6 +787,10 @@ static int do_login(GtkWindow *parent) {
|
||||
g_state.method = result.method;
|
||||
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
|
||||
g_state.pubkey_hex[64] = '\0';
|
||||
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
|
||||
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
|
||||
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
|
||||
g_state.privkey_hex[64] = '\0';
|
||||
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
|
||||
result.method == KEY_STORE_METHOD_NONE);
|
||||
g_logged_in = TRUE;
|
||||
@@ -586,9 +819,9 @@ static int do_login(GtkWindow *parent) {
|
||||
|
||||
/* Track the currently-open per-user db path so we can skip re-opening
|
||||
* the same database (e.g. when app_set_signer() is called during the
|
||||
* login dialog and then main() calls switch_to_user_db() again). */
|
||||
static char g_current_profile_db[512] = "";
|
||||
|
||||
* login dialog and then main() calls switch_to_user_db() again).
|
||||
* The actual definition is near the top of this file (forward-declared
|
||||
* before the menu proxies that reset it on logout). */
|
||||
static int switch_to_user_db(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[profile] No pubkey, staying on global.db\n");
|
||||
@@ -643,6 +876,52 @@ static int switch_to_user_db(const char *pubkey_hex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Per-user WebKit context build helper --------------------------- *
|
||||
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
|
||||
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
|
||||
* and updates tab_manager so new tabs use the new context. Called from:
|
||||
* - main() after login, before creating tabs
|
||||
* - app_menu_switch_identity_proxy() after tearing down the old context
|
||||
* - app_menu_logout_proxy() after re-login
|
||||
*
|
||||
* The caller must have torn down the previous context (via
|
||||
* web_context_teardown()) and closed all tabs/sidebar webviews first.
|
||||
* Returns the new context, or NULL on failure (the caller may fall back
|
||||
* to a blank window or exit).
|
||||
*/
|
||||
static WebKitWebContext *build_context_for_current_user(void) {
|
||||
WebKitWebContext *web_ctx;
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
|
||||
} else {
|
||||
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
|
||||
* persistence. See plans/webkit-data-isolation.md. */
|
||||
web_ctx = web_context_build_ephemeral();
|
||||
}
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Failed to build WebKit context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Register the sovereign:// URI scheme for the window.nostr bridge.
|
||||
* nostr_bridge_set_signer() was already called by the login path,
|
||||
* so the bridge will use the current signer. */
|
||||
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
|
||||
/* Register nostr:// and tor:// entity-page schemes on this context.
|
||||
* These are per-context registrations, so they must be re-registered
|
||||
* on every fresh context. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
tor_scheme_register(web_ctx);
|
||||
|
||||
/* Point tab_manager at the new context so subsequent new tabs (and
|
||||
* the sidebar webview) are created from it. */
|
||||
tab_manager_set_context(web_ctx);
|
||||
|
||||
return web_ctx;
|
||||
}
|
||||
|
||||
/* ---- Main ----------------------------------------------------------- */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@@ -746,6 +1025,8 @@ int main(int argc, char **argv) {
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
|
||||
g_signal_connect(window, "delete-event",
|
||||
G_CALLBACK(on_window_delete_event), NULL);
|
||||
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
|
||||
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
|
||||
g_window = GTK_WINDOW(window);
|
||||
@@ -875,50 +1156,35 @@ int main(int argc, char **argv) {
|
||||
* when the relay fetch completes and the kind 0 event is stored. */
|
||||
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Use the default WebKitWebContext — it comes with proper networking
|
||||
* (cookies, cache, soup session) that a freshly created context lacks.
|
||||
* All tabs will create webviews from this shared context. */
|
||||
WebKitWebContext *web_ctx = webkit_web_context_get_default();
|
||||
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
|
||||
* Build a fresh WebKitWebContext with a per-user
|
||||
* WebKitWebsiteDataManager rooted at
|
||||
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
|
||||
* data manager for --no-login mode). This gives true per-identity
|
||||
* isolation of cookies, cache, localStorage, IndexedDB, service
|
||||
* workers, and favicons. The helper also registers the sovereign://,
|
||||
* nostr://, tor:// URI schemes on the new context and points
|
||||
* tab_manager at it. */
|
||||
WebKitWebContext *web_ctx = build_context_for_current_user();
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Cannot continue without a WebKit context.\n");
|
||||
agent_server_stop();
|
||||
nostr_cleanup();
|
||||
cli_args_free(&cli);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
/* ── Security strip: disable web security restrictions ─────── */
|
||||
/* Fetch the security manager for the new context — used below to
|
||||
* wire the sovereign://security page to the first tab's settings. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(web_ctx);
|
||||
/* Register sovereign:// as secure only. Do NOT register it as "local"
|
||||
* (WebKit blocks fetch from https to local origins) and do NOT register
|
||||
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
|
||||
* which we can't set with the basic finish API). Without these, WebKit
|
||||
* treats sovereign:// as a simple secure scheme with no CORS checks. */
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "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);
|
||||
@@ -926,6 +1192,11 @@ int main(int argc, char **argv) {
|
||||
/* Initialize the tab manager. */
|
||||
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
|
||||
|
||||
/* Mark startup complete. From now on, app_set_signer() (called by
|
||||
* agent login / switch_identity) will rebuild the per-user web
|
||||
* context if it was torn down, since main() won't build it again. */
|
||||
g_post_startup = TRUE;
|
||||
|
||||
/* Decide whether to restore the previous session or open CLI URLs.
|
||||
*
|
||||
* Precedence:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1234
src/nostr_bridge.c
1234
src/nostr_bridge.c
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,39 @@ static const char *NOSTR_SHIM_JS =
|
||||
" }\n"
|
||||
"})();\n";
|
||||
|
||||
/* Wheel-scroll workaround for WebKitGTK.
|
||||
*
|
||||
* On some pages (e.g. jumble.social) WebKitGTK dispatches JS wheel events
|
||||
* but does not perform the native scroll action — so the scrollbar works
|
||||
* (native GDK scroll) but the mouse wheel doesn't scroll the page. This
|
||||
* script installs a non-passive wheel listener on window that, when the
|
||||
* page's main scroll container is the document (the common case), calls
|
||||
* preventDefault() and manually scrolls by the wheel delta. It only
|
||||
* intervenes when the target is the document/body/html or an element with
|
||||
* no own scroll (overflow: visible), so nested scroll containers (which
|
||||
* WebKitGTK handles correctly) are not broken. */
|
||||
static const char *WHEEL_SCROLL_JS =
|
||||
"(function(){\n"
|
||||
" 'use strict';\n"
|
||||
" function isMainScrollTarget(el){\n"
|
||||
" if(!el) return false;\n"
|
||||
" if(el===document||el===document.documentElement||el===document.body) return true;\n"
|
||||
" var cs=getComputedStyle(el);\n"
|
||||
" return (cs.overflowY==='visible'||cs.overflowY==='') && el.scrollHeight<=el.clientHeight;\n"
|
||||
" }\n"
|
||||
" window.addEventListener('wheel',function(e){\n"
|
||||
" if(e.defaultPrevented) return;\n"
|
||||
" if(!isMainScrollTarget(e.target)) return;\n"
|
||||
" var html=document.documentElement;\n"
|
||||
" if(html.scrollHeight<=html.clientHeight) return;\n"
|
||||
" e.preventDefault();\n"
|
||||
" var dy=e.deltaY;\n"
|
||||
" if(e.deltaMode===1) dy*=40;\n"
|
||||
" else if(e.deltaMode===2) dy*=html.clientHeight;\n"
|
||||
" window.scrollBy(0,dy);\n"
|
||||
" },{capture:false,passive:false});\n"
|
||||
"})();\n";
|
||||
|
||||
void nostr_inject_setup(WebKitWebView *webview) {
|
||||
WebKitUserContentManager *manager =
|
||||
webkit_web_view_get_user_content_manager(webview);
|
||||
@@ -148,5 +181,14 @@ void nostr_inject_setup(WebKitWebView *webview) {
|
||||
/* The manager takes ownership of the script. */
|
||||
webkit_user_script_unref(script);
|
||||
|
||||
g_print("[inject] window.nostr shim installed\n");
|
||||
/* Wheel-scroll workaround — see WHEEL_SCROLL_JS comment above. */
|
||||
WebKitUserScript *wheel = webkit_user_script_new(
|
||||
WHEEL_SCROLL_JS,
|
||||
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, wheel);
|
||||
webkit_user_script_unref(wheel);
|
||||
}
|
||||
|
||||
101
src/perf_probe.c
Normal file
101
src/perf_probe.c
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* perf_probe.c — per-tab performance probe injection
|
||||
*
|
||||
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
|
||||
* and is embedded into the binary by embed_web_files.sh. This module
|
||||
* wraps it with a per-tab preamble that injects a
|
||||
* <meta name="sb-tab-index" content="N"> tag into the document before
|
||||
* the probe runs, so the probe can identify which tab it is reporting
|
||||
* for.
|
||||
*
|
||||
* The preamble also short-circuits on sovereign:// pages (the probe
|
||||
* would be self-referential and noisy on internal pages). We can't
|
||||
* filter by URL at injection time because WebKitUserScript is added
|
||||
* once per webview and applies to all future loads, so the URL check
|
||||
* happens at runtime inside the preamble.
|
||||
*/
|
||||
|
||||
#include "perf_probe.h"
|
||||
#include "embedded_web_content.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* The preamble runs at document_start, before perf-probe.js. It:
|
||||
* 1. Checks the current URL — if it's a sovereign:// internal page,
|
||||
* sets a flag so the probe body no-ops.
|
||||
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
|
||||
* probe (which runs at document_start as well, but after this
|
||||
* preamble in script-order) can read its tab index.
|
||||
*
|
||||
* The tab index is baked in at injection time as a literal. */
|
||||
static char *build_preamble(int tab_index) {
|
||||
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
|
||||
char *p = g_malloc(512);
|
||||
if (p == NULL) return NULL;
|
||||
snprintf(p, 512,
|
||||
"(function(){\n"
|
||||
" 'use strict';\n"
|
||||
" try {\n"
|
||||
" var u = location.href || '';\n"
|
||||
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
|
||||
" window.__sbPerfProbeSkip = true;\n"
|
||||
" } else {\n"
|
||||
" window.__sbPerfProbeSkip = false;\n"
|
||||
" window.__sbPerfTabIndex = %d;\n"
|
||||
" }\n"
|
||||
" } catch(e) {}\n"
|
||||
"})();\n",
|
||||
tab_index);
|
||||
return p;
|
||||
}
|
||||
|
||||
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
|
||||
WebKitUserContentManager *manager =
|
||||
webkit_web_view_get_user_content_manager(webview);
|
||||
if (manager == NULL) {
|
||||
g_printerr("[perf-probe] No user content manager — probe not injected\n");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
|
||||
char *preamble = build_preamble(tab_index);
|
||||
if (preamble == NULL) return;
|
||||
|
||||
WebKitUserScript *pre_script = webkit_user_script_new(
|
||||
preamble,
|
||||
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL, /* allow list (NULL = all) */
|
||||
NULL /* block list */
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, pre_script);
|
||||
webkit_user_script_unref(pre_script);
|
||||
g_free(preamble);
|
||||
|
||||
/* The probe body itself, from the embedded www/js/perf-probe.js. */
|
||||
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
|
||||
if (f == NULL) {
|
||||
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
|
||||
return;
|
||||
}
|
||||
/* webkit_user_script_new needs a NUL-terminated string. The embedded
|
||||
* data is a byte array of known size; copy it into a NUL-terminated
|
||||
* buffer. */
|
||||
char *probe_js = g_malloc(f->size + 1);
|
||||
if (probe_js == NULL) return;
|
||||
memcpy(probe_js, f->data, f->size);
|
||||
probe_js[f->size] = '\0';
|
||||
|
||||
WebKitUserScript *probe_script = webkit_user_script_new(
|
||||
probe_js,
|
||||
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
webkit_user_content_manager_add_script(manager, probe_script);
|
||||
webkit_user_script_unref(probe_script);
|
||||
g_free(probe_js);
|
||||
|
||||
}
|
||||
36
src/perf_probe.h
Normal file
36
src/perf_probe.h
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* perf_probe.h — per-tab performance probe injection
|
||||
*
|
||||
* Injects www/js/perf-probe.js into a webview via
|
||||
* WebKitUserContentManager, prefixed with a tiny preamble that sets
|
||||
* the tab index (so the probe knows which tab it is reporting for).
|
||||
*
|
||||
* The probe is skipped on sovereign:// internal pages to avoid
|
||||
* self-noise and recursion. Call once per webview at tab creation,
|
||||
* after nostr_inject_setup().
|
||||
*/
|
||||
|
||||
#ifndef PERF_PROBE_H
|
||||
#define PERF_PROBE_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Inject the perf probe into the given webview, tagged with the given
|
||||
* tab index. The index is baked into a preamble that runs before the
|
||||
* probe script and sets window.__sbPerfTabIndex.
|
||||
*
|
||||
* Safe to call multiple times on the same webview (the probe guards
|
||||
* against double-injection).
|
||||
*/
|
||||
void perf_probe_setup(WebKitWebView *webview, int tab_index);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PERF_PROBE_H */
|
||||
558
src/process_info.c
Normal file
558
src/process_info.c
Normal file
@@ -0,0 +1,558 @@
|
||||
/*
|
||||
* process_info.c — process and per-tab performance diagnostics
|
||||
*
|
||||
* See process_info.h for the two-layer design. This file implements:
|
||||
*
|
||||
* Layer 1 — /proc enumeration for the main PID, WebKit child PIDs
|
||||
* (discovered via webkit_web_view_get_process_id() per tab
|
||||
* plus a PPID scan for network/gpu/storage processes), and
|
||||
* managed Tor/FIPS PIDs from net_services. CPU% is computed
|
||||
* from utime+stime deltas between successive calls.
|
||||
*
|
||||
* Layer 2 — storage of the most recent perf-probe.js report per tab
|
||||
* index, exposed to the UI and MCP tools.
|
||||
*
|
||||
* All /proc parsing is Linux-specific and uses only libc + glib.
|
||||
*/
|
||||
|
||||
#include "process_info.h"
|
||||
#include "tab_manager.h"
|
||||
#include "net_services.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <dirent.h>
|
||||
#include <ctype.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
|
||||
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
|
||||
} cpu_sample_t;
|
||||
|
||||
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
|
||||
|
||||
static cpu_sample_t *cpu_sample_get(guint pid) {
|
||||
if (g_cpu_samples == NULL) {
|
||||
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
|
||||
NULL, g_free);
|
||||
}
|
||||
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
|
||||
if (s == NULL) {
|
||||
s = g_new0(cpu_sample_t, 1);
|
||||
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static long g_clk_tck = 0;
|
||||
static long clk_tck(void) {
|
||||
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
|
||||
return g_clk_tck > 0 ? g_clk_tck : 100;
|
||||
}
|
||||
|
||||
/* ── /proc readers ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Read the first line of a /proc file into buf (NUL-terminated). */
|
||||
static int read_proc_file(const char *path, char *buf, size_t buflen) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
size_t n = fread(buf, 1, buflen - 1, f);
|
||||
buf[n] = '\0';
|
||||
fclose(f);
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
|
||||
* (2) comm (3) state (4) ppid (14) utime (15) stime
|
||||
* (22) starttime (in clock ticks since boot)
|
||||
* comm is wrapped in parens and may contain spaces, so we parse by
|
||||
* finding the last ')' and tokenizing after it. */
|
||||
static int parse_stat(guint pid, char *state_out, size_t state_sz,
|
||||
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
|
||||
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
|
||||
char buf[4096];
|
||||
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
|
||||
|
||||
/* comm: between first '(' and last ')'. */
|
||||
char *lp = strchr(buf, '(');
|
||||
char *rp = strrchr(buf, ')');
|
||||
if (lp == NULL || rp == NULL || rp <= lp) return -1;
|
||||
size_t comm_len = (size_t)(rp - lp - 1);
|
||||
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
|
||||
memcpy(comm_out, lp + 1, comm_len);
|
||||
comm_out[comm_len] = '\0';
|
||||
|
||||
/* The rest after ") " is space-separated fields starting at field 3. */
|
||||
char *rest = rp + 2;
|
||||
/* rest = "state ppid ... " — tokenize. */
|
||||
char *save = NULL;
|
||||
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
|
||||
if (tok == NULL) return -1;
|
||||
if (state_out) {
|
||||
size_t sl = strlen(tok);
|
||||
if (sl >= state_sz) sl = state_sz - 1;
|
||||
memcpy(state_out, tok, sl);
|
||||
state_out[sl] = '\0';
|
||||
}
|
||||
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
|
||||
if (tok == NULL) return -1;
|
||||
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
|
||||
|
||||
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
|
||||
* majflt, cmajflt. Skip them. */
|
||||
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
|
||||
if (tok == NULL) return -1;
|
||||
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
|
||||
if (tok == NULL) return -1;
|
||||
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
|
||||
|
||||
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
|
||||
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
|
||||
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
|
||||
if (tok == NULL) return -1;
|
||||
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
|
||||
* the integer value in KB, or -1 if not found. */
|
||||
static long status_field_kb(guint pid, const char *field) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/status", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long val = -1;
|
||||
size_t flen = strlen(field);
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, field, flen) == 0) {
|
||||
/* e.g. "VmRSS: 12345 kB" */
|
||||
char *p = line + flen;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
val = strtol(p, NULL, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return val;
|
||||
}
|
||||
|
||||
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
|
||||
* Returns 0 on success. */
|
||||
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long pss = -1, priv_clean = 0, priv_dirty = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "Pss:", 4) == 0) {
|
||||
pss = strtol(line + 4, NULL, 10);
|
||||
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
|
||||
priv_clean = strtol(line + 14, NULL, 10);
|
||||
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
|
||||
priv_dirty = strtol(line + 14, NULL, 10);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (pss_kb) *pss_kb = pss;
|
||||
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
|
||||
return (pss < 0) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Read /proc/<pid>/io fields. Returns 0 on success. */
|
||||
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/io", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) return -1;
|
||||
char line[256];
|
||||
long rb = 0, wb = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "read_bytes:", 11) == 0) {
|
||||
rb = strtoll(line + 11, NULL, 10);
|
||||
} else if (strncmp(line, "write_bytes:", 12) == 0) {
|
||||
wb = strtoll(line + 12, NULL, 10);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
if (read_bytes) *read_bytes = rb;
|
||||
if (write_bytes) *write_bytes = wb;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
|
||||
static void read_cmdline(guint pid, char *buf, size_t buflen) {
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) { buf[0] = '\0'; return; }
|
||||
size_t n = fread(buf, 1, buflen - 1, f);
|
||||
fclose(f);
|
||||
if (n == 0) { buf[0] = '\0'; return; }
|
||||
buf[n] = '\0';
|
||||
/* Replace NUL separators with spaces. */
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (buf[i] == '\0') buf[i] = ' ';
|
||||
}
|
||||
/* Trim trailing space. */
|
||||
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
|
||||
}
|
||||
|
||||
/* Count entries in a /proc directory (used for threads and fds). */
|
||||
static int count_dir_entries(const char *path) {
|
||||
DIR *d = opendir(path);
|
||||
if (d == NULL) return -1;
|
||||
int count = 0;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
if (e->d_name[0] == '.') continue;
|
||||
count++;
|
||||
}
|
||||
closedir(d);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Read /proc/stat's btime (boot time in seconds since epoch). */
|
||||
static time_t g_btime = 0;
|
||||
static time_t boot_time(void) {
|
||||
if (g_btime != 0) return g_btime;
|
||||
FILE *f = fopen("/proc/stat", "r");
|
||||
if (f == NULL) return 0;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, "btime", 5) == 0) {
|
||||
g_btime = (time_t)strtoll(line + 5, NULL, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return g_btime;
|
||||
}
|
||||
|
||||
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
cJSON *probe; /* most recent probe report (owned) */
|
||||
time_t ts; /* when recorded */
|
||||
} probe_slot_t;
|
||||
|
||||
/* Sparse array indexed by tab index. Grows as needed. */
|
||||
static probe_slot_t *g_probes = NULL;
|
||||
static int g_probe_cap = 0;
|
||||
|
||||
static void probe_ensure(int idx) {
|
||||
if (idx < 0) return;
|
||||
if (idx >= g_probe_cap) {
|
||||
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
|
||||
while (new_cap <= idx) new_cap *= 2;
|
||||
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
|
||||
if (arr == NULL) return;
|
||||
for (int i = g_probe_cap; i < new_cap; i++) {
|
||||
arr[i].probe = NULL;
|
||||
arr[i].ts = 0;
|
||||
}
|
||||
g_probes = arr;
|
||||
g_probe_cap = new_cap;
|
||||
}
|
||||
}
|
||||
|
||||
void process_info_record_probe(int tab_index, cJSON *probe) {
|
||||
if (tab_index < 0 || probe == NULL) {
|
||||
if (probe) cJSON_Delete(probe);
|
||||
return;
|
||||
}
|
||||
probe_ensure(tab_index);
|
||||
if (tab_index >= g_probe_cap) {
|
||||
cJSON_Delete(probe);
|
||||
return;
|
||||
}
|
||||
if (g_probes[tab_index].probe) {
|
||||
cJSON_Delete(g_probes[tab_index].probe);
|
||||
}
|
||||
g_probes[tab_index].probe = probe; /* take ownership */
|
||||
g_probes[tab_index].ts = time(NULL);
|
||||
}
|
||||
|
||||
/* Return a *reference* (not a copy) to the stored probe for a tab, or
|
||||
* NULL. Caller must NOT free. */
|
||||
static cJSON *probe_get(int tab_index) {
|
||||
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
|
||||
return g_probes[tab_index].probe;
|
||||
}
|
||||
|
||||
/* Deep-copy a probe (so the caller can free independently). */
|
||||
static cJSON *probe_copy(int tab_index) {
|
||||
cJSON *p = probe_get(tab_index);
|
||||
if (p == NULL) return NULL;
|
||||
char *s = cJSON_PrintUnformatted(p);
|
||||
cJSON *copy = cJSON_Parse(s);
|
||||
free(s);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* ── Process enumeration ────────────────────────────────────────────── */
|
||||
|
||||
/* Ownership tag for a discovered PID. */
|
||||
typedef enum {
|
||||
OWN_MAIN,
|
||||
OWN_WEBKIT_RENDERER,
|
||||
OWN_WEBKIT_NETWORK,
|
||||
OWN_WEBKIT_GPU,
|
||||
OWN_WEBKIT_STORAGE,
|
||||
OWN_TOR,
|
||||
OWN_FIPS,
|
||||
OWN_OTHER
|
||||
} proc_own_t;
|
||||
|
||||
/* Build a cJSON object for one process. `own` and `service_state`
|
||||
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
|
||||
* array (may be NULL) for renderers. */
|
||||
static cJSON *build_proc_obj(guint pid, proc_own_t own,
|
||||
const char *service_state,
|
||||
cJSON *hosted_tabs) {
|
||||
char comm[256] = "";
|
||||
char state[8] = "?";
|
||||
guint ppid = 0;
|
||||
guint64 utime = 0, stime = 0, starttime = 0;
|
||||
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
|
||||
&starttime, comm, sizeof(comm)) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
|
||||
* resolution) instead of time(NULL) (1-second resolution) so the
|
||||
* value updates on every poll even when the poll interval is ~1s. */
|
||||
cpu_sample_t *s = cpu_sample_get(pid);
|
||||
guint64 cur_jiffies = utime + stime;
|
||||
gint64 now_us = g_get_monotonic_time();
|
||||
double cpu_pct = 0.0;
|
||||
if (s->prev_wall_us > 0) {
|
||||
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
|
||||
if (dt > 0) {
|
||||
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
|
||||
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
|
||||
if (cpu_pct < 0) cpu_pct = 0;
|
||||
}
|
||||
}
|
||||
s->prev_jiffies = cur_jiffies;
|
||||
s->prev_wall_us = now_us;
|
||||
|
||||
long rss_kb = status_field_kb(pid, "VmRSS:");
|
||||
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
|
||||
long vmswap_kb = status_field_kb(pid, "VmSwap:");
|
||||
long pss_kb = -1, uss_kb = -1;
|
||||
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
|
||||
long io_read = 0, io_write = 0;
|
||||
parse_io(pid, &io_read, &io_write);
|
||||
|
||||
char task_path[64];
|
||||
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
|
||||
int threads = count_dir_entries(task_path);
|
||||
if (threads < 0) threads = 0;
|
||||
|
||||
char fd_path[64];
|
||||
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
|
||||
int fds = count_dir_entries(fd_path);
|
||||
if (fds < 0) fds = 0;
|
||||
|
||||
char cmdline[1024];
|
||||
read_cmdline(pid, cmdline, sizeof(cmdline));
|
||||
|
||||
/* Uptime: starttime is in ticks since boot; btime is boot seconds
|
||||
* since epoch. process_start = btime + starttime/clk. */
|
||||
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
|
||||
time_t uptime = time(NULL) - start_sec;
|
||||
if (uptime < 0) uptime = 0;
|
||||
|
||||
const char *own_str = "other";
|
||||
switch (own) {
|
||||
case OWN_MAIN: own_str = "main"; break;
|
||||
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
|
||||
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
|
||||
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
|
||||
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
|
||||
case OWN_TOR: own_str = "tor"; break;
|
||||
case OWN_FIPS: own_str = "fips"; break;
|
||||
default: own_str = "other"; break;
|
||||
}
|
||||
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "pid", (double)pid);
|
||||
cJSON_AddStringToObject(o, "name", comm);
|
||||
cJSON_AddStringToObject(o, "cmdline", cmdline);
|
||||
cJSON_AddStringToObject(o, "state", state);
|
||||
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
|
||||
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
|
||||
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
|
||||
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
|
||||
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
|
||||
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
|
||||
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
|
||||
cJSON_AddNumberToObject(o, "threads", threads);
|
||||
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
|
||||
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
|
||||
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
|
||||
cJSON_AddNumberToObject(o, "fd_count", fds);
|
||||
cJSON_AddStringToObject(o, "ownership", own_str);
|
||||
if (service_state) {
|
||||
cJSON_AddStringToObject(o, "service_state", service_state);
|
||||
}
|
||||
if (hosted_tabs) {
|
||||
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
|
||||
} else {
|
||||
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/* Map a net_service_type_t to a service_state string via net_services. */
|
||||
static const char *service_state_for(net_service_type_t t) {
|
||||
const net_service_t *s = net_service_get_status(t);
|
||||
if (s == NULL) return NULL;
|
||||
switch (s->state) {
|
||||
case SERVICE_DISABLED: return "disabled";
|
||||
case SERVICE_DISCOVERING: return "discovering";
|
||||
case SERVICE_ATTACHING: return "attaching";
|
||||
case SERVICE_STARTING: return "starting";
|
||||
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
|
||||
case SERVICE_READY: return "ready";
|
||||
case SERVICE_STOPPING: return "stopping";
|
||||
case SERVICE_EXITED: return "exited";
|
||||
case SERVICE_FAILED: return "failed";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* Scan /proc for child processes of main_pid whose comm starts with
|
||||
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
|
||||
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
|
||||
* (WebKitStorageProcess) and adds it to the `out` array with the
|
||||
* appropriate ownership tag.
|
||||
*
|
||||
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
|
||||
* cannot map individual tabs to specific renderer PIDs here. Per-tab
|
||||
* CPU attribution is handled by the Layer 2 probe instead. Renderer
|
||||
* rows therefore show an empty hosted_tabs array. */
|
||||
static void scan_webkit_procs(guint main_pid, cJSON *out) {
|
||||
DIR *d = opendir("/proc");
|
||||
if (d == NULL) return;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
if (!isdigit((unsigned char)e->d_name[0])) continue;
|
||||
guint pid = (guint)strtoul(e->d_name, NULL, 10);
|
||||
if (pid == 0 || pid == main_pid) continue;
|
||||
char comm[256] = "";
|
||||
char state[8] = "?";
|
||||
guint ppid = 0;
|
||||
guint64 utime = 0, stime = 0, starttime = 0;
|
||||
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
|
||||
&starttime, comm, sizeof(comm)) != 0) continue;
|
||||
if (ppid != main_pid) continue;
|
||||
if (strncmp(comm, "WebKit", 6) != 0) continue;
|
||||
proc_own_t own = OWN_OTHER;
|
||||
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
|
||||
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
|
||||
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
|
||||
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
|
||||
else continue; /* unknown WebKit aux — skip */
|
||||
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
|
||||
if (obj) cJSON_AddItemToArray(out, obj);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
cJSON *process_info_get_processes_json(void) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
guint main_pid = (guint)getpid();
|
||||
|
||||
/* Main process. */
|
||||
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
|
||||
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
|
||||
|
||||
/* WebKit child processes (renderers, network, gpu, storage).
|
||||
* Discovered via /proc PPID scan — WebKitGTK 4.1 doesn't expose
|
||||
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
|
||||
scan_webkit_procs(main_pid, arr);
|
||||
|
||||
/* Tor + FIPS managed subprocesses. */
|
||||
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
|
||||
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
|
||||
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
|
||||
service_state_for(NET_SERVICE_TOR), NULL);
|
||||
if (obj) cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
|
||||
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
|
||||
service_state_for(NET_SERVICE_FIPS), NULL);
|
||||
if (obj) cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
|
||||
|
||||
cJSON *process_info_get_tabs_json(void) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
int n = tab_manager_count();
|
||||
for (int i = 0; i < n; i++) {
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
if (tab == NULL) continue;
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "index", i);
|
||||
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
|
||||
cJSON_AddStringToObject(o, "url",
|
||||
tab->current_url[0] ? tab->current_url : "");
|
||||
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
|
||||
cJSON_AddBoolToObject(o, "is_internal", is_internal);
|
||||
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
|
||||
* report 0 here. The Processes tab lists renderer PIDs from
|
||||
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
|
||||
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
|
||||
cJSON *probe = probe_copy(i);
|
||||
if (probe) {
|
||||
cJSON_AddItemToObject(o, "probe", probe);
|
||||
} else {
|
||||
cJSON_AddNullToObject(o, "probe");
|
||||
}
|
||||
cJSON_AddItemToArray(arr, o);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index) {
|
||||
tab_info_t *tab = tab_manager_get(tab_index);
|
||||
if (tab == NULL) return NULL;
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(root, "index", tab_index);
|
||||
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
|
||||
cJSON_AddStringToObject(root, "url",
|
||||
tab->current_url[0] ? tab->current_url : "");
|
||||
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
|
||||
cJSON_AddBoolToObject(root, "is_internal", is_internal);
|
||||
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
|
||||
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
|
||||
cJSON *probe = probe_copy(tab_index);
|
||||
if (probe) {
|
||||
cJSON_AddItemToObject(root, "probe", probe);
|
||||
} else {
|
||||
cJSON_AddNullToObject(root, "probe");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
82
src/process_info.h
Normal file
82
src/process_info.h
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* process_info.h — process and per-tab performance diagnostics
|
||||
*
|
||||
* Provides the data backing for the sovereign://processes internal page.
|
||||
* Two layers:
|
||||
*
|
||||
* Layer 1 — process view: enumerates the main browser PID, the WebKit
|
||||
* child processes (renderer / network / gpu / storage) and
|
||||
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
|
||||
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
|
||||
* service state. CPU% is computed from utime+stime deltas
|
||||
* between successive calls (top-style).
|
||||
*
|
||||
* Layer 2 — per-tab probe: a JS user script (www/js/perf-probe.js)
|
||||
* injected into every non-sovereign:// page reports a
|
||||
* 1-second batch of long-task, timer, network, heap, and
|
||||
* FPS samples via sovereign://processes/probe-report. The
|
||||
* latest report per tab index is stored here and exposed to
|
||||
* the UI and to MCP tools.
|
||||
*
|
||||
* All /proc parsing is Linux-specific and dependency-free.
|
||||
*/
|
||||
|
||||
#ifndef PROCESS_INFO_H
|
||||
#define PROCESS_INFO_H
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Build the Layer 1 process list as a cJSON array (caller frees).
|
||||
* Shape:
|
||||
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
|
||||
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
|
||||
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
|
||||
* "threads":12, "uptime_sec":..., "io_read_kb":...,
|
||||
* "io_write_kb":..., "fd_count":..., "ownership":"main",
|
||||
* "service_state":"ready" (Tor/FIPS only),
|
||||
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
|
||||
* (renderers only) }]
|
||||
*
|
||||
* The first call returns cpu_percent 0 for every process (no baseline);
|
||||
* subsequent calls return accurate deltas. Caller should poll ~1s.
|
||||
*/
|
||||
cJSON *process_info_get_processes_json(void);
|
||||
|
||||
/*
|
||||
* Build the Layer 2 tab list as a cJSON array (caller frees).
|
||||
* Combines tab_manager state (index, title, url, WebProcess pid) with
|
||||
* the most recent probe report for each tab (if any).
|
||||
* Shape:
|
||||
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
|
||||
* "is_internal":true,
|
||||
* "probe": { ...latest probe report, or null... } }]
|
||||
*/
|
||||
cJSON *process_info_get_tabs_json(void);
|
||||
|
||||
/*
|
||||
* Build a drill-down JSON object for a single tab (caller frees).
|
||||
* Returns the full latest probe report (including the long-task
|
||||
* timeline and top sources) plus the tab's identity fields.
|
||||
* Returns NULL if the tab index is out of range.
|
||||
*/
|
||||
cJSON *process_info_get_tab_probe_json(int tab_index);
|
||||
|
||||
/*
|
||||
* Record a probe report received from a page's perf-probe.js.
|
||||
* Takes ownership of `probe` (frees it). `tab_index` is the
|
||||
* sovereign://processes/probe-report?tab_index=N query value.
|
||||
* Stores a copy of the report fields; the timeline and top_sources
|
||||
* arrays are preserved for the drill-down view.
|
||||
*/
|
||||
void process_info_record_probe(int tab_index, cJSON *probe);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PROCESS_INFO_H */
|
||||
@@ -106,6 +106,46 @@ int profile_ensure_dir(const char *pubkey_hex) {
|
||||
return mkdir_p(dir, 0700);
|
||||
}
|
||||
|
||||
/* ── WebKit data directory ─────────────────────────────────────────── *
|
||||
* Each user's WebKitWebsiteDataManager (cookies, cache, localStorage,
|
||||
* IndexedDB, service workers, favicons) is rooted at
|
||||
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ so web data is isolated
|
||||
* per identity and persists across restarts. See plans/webkit-data-isolation.md.
|
||||
*/
|
||||
|
||||
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
int n = snprintf(out, out_sz, "%swebkit/", dir);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
if (out_sz > 0) out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int profile_ensure_webkit_dir(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char dir[SB_HOME_BUF_SZ];
|
||||
profile_get_webkit_dir(pubkey_hex, dir, sizeof(dir));
|
||||
if (dir[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return mkdir_p(dir, 0700);
|
||||
}
|
||||
|
||||
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) {
|
||||
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
|
||||
if (out && out_sz > 0) out[0] = '\0';
|
||||
|
||||
@@ -47,6 +47,21 @@ void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
*/
|
||||
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Get the per-user WebKit data directory for a given hex pubkey.
|
||||
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/webkit/" to out.
|
||||
* This is where the per-user WebKitWebsiteDataManager roots its cookies,
|
||||
* cache, localStorage, IndexedDB, service workers, and favicons so web
|
||||
* data is isolated per identity. See plans/webkit-data-isolation.md.
|
||||
*/
|
||||
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz);
|
||||
|
||||
/*
|
||||
* Create the per-user WebKit data directory if it doesn't already exist
|
||||
* (mkdir -p style). Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int profile_ensure_webkit_dir(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Get the global identity.json path (stores the last-used pubkey_hex so
|
||||
* the login dialog can default to the right profile).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,7 @@ typedef enum {
|
||||
SHORTCUT_GO_FORWARD,
|
||||
SHORTCUT_FIND,
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_OPEN_PROCESSES,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
|
||||
1069
src/tab_manager.c
1069
src/tab_manager.c
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ 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 *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 +47,18 @@ void tab_manager_init(GtkContainer *parent,
|
||||
WebKitWebContext *ctx,
|
||||
GtkWindow *window);
|
||||
|
||||
/*
|
||||
* Update the WebKitWebContext that tab_manager_new_tab() and
|
||||
* sidebar_create_webview() use to create new webviews. Used during an
|
||||
* identity switch: the old context is torn down (after all tabs are
|
||||
* closed) and a fresh per-user context is built; this function updates
|
||||
* the stored pointer so subsequent new tabs use the new context.
|
||||
*
|
||||
* The caller MUST have closed all existing tabs and sidebar webviews
|
||||
* (which reference the old context) before calling this.
|
||||
*/
|
||||
void tab_manager_set_context(WebKitWebContext *ctx);
|
||||
|
||||
/*
|
||||
* Create a new tab and load the given URL (or the default new-tab URL
|
||||
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
|
||||
@@ -141,6 +154,25 @@ void tab_manager_close_to_right(int index);
|
||||
*/
|
||||
void tab_manager_close_all(void);
|
||||
|
||||
/*
|
||||
* Destroy the agent chat sidebar webview in every open window (main + aux).
|
||||
* The sidebar container itself is kept (it will be re-populated lazily on the
|
||||
* next toggle). Used during logout / identity switch so the sidebar — which
|
||||
* holds a long-lived WebKitWebView with the previous user's session — does
|
||||
* not leak the old identity's sovereign://agents/chat state across users.
|
||||
*/
|
||||
void tab_manager_destroy_sidebar_webviews(void);
|
||||
|
||||
/*
|
||||
* Temporarily suppress the "last tab closed → quit the app" behavior.
|
||||
* Set TRUE before tab_manager_close_all() during an identity switch so
|
||||
* the browser does not exit when the previous user's tabs are closed;
|
||||
* the caller opens a fresh tab for the new user afterwards. Always
|
||||
* reset to FALSE when done so normal quit-on-last-tab-close behavior
|
||||
* is restored.
|
||||
*/
|
||||
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
|
||||
|
||||
/*
|
||||
* Duplicate the tab at the given index (open a new tab with the same URL).
|
||||
*/
|
||||
@@ -208,6 +240,14 @@ WebKitWebView *tab_manager_get_main_webview(void);
|
||||
*/
|
||||
gboolean tab_manager_sidebar_visible(void);
|
||||
|
||||
/*
|
||||
* Returns the main window's GtkNotebook widget. Used by main.c's
|
||||
* delete-event handler to identify which tabs belong to the main window
|
||||
* (vs auxiliary windows) when closing the main window but keeping aux
|
||||
* windows alive.
|
||||
*/
|
||||
GtkWidget *tab_manager_get_main_notebook(void);
|
||||
|
||||
/*
|
||||
* Re-hide the sidebar container after gtk_widget_show_all(window).
|
||||
* Call this in main.c after show_all so the sidebar (which is packed
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.36"
|
||||
#define SB_VERSION "v0.0.56"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 36
|
||||
#define SB_VERSION_PATCH 56
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
156
src/web_context.c
Normal file
156
src/web_context.c
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* web_context.c — per-user WebKitWebContext construction/teardown
|
||||
*
|
||||
* See web_context.h for the rationale. Phase A of
|
||||
* plans/webkit-data-isolation.md.
|
||||
*/
|
||||
|
||||
#include "web_context.h"
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* The current per-user context. NULL before the first build and after
|
||||
* teardown. Replaces webkit_web_context_get_default() for code that
|
||||
* wants the per-user context. */
|
||||
static WebKitWebContext *g_ctx = NULL;
|
||||
|
||||
/* ── Security / TLS / favicon configuration ────────────────────────── *
|
||||
* Applied to every context we build (per-user and ephemeral). Mirrors
|
||||
* the configuration that main.c used to apply to the default context.
|
||||
*/
|
||||
static void configure_context(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
|
||||
/* Security strip: register sovereign://, tor://, file:// as secure
|
||||
* (no CORS enforcement), and file:// as local. Same as the original
|
||||
* main.c configuration. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
WebKitWebsiteDataManager *dm =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* Enable the favicon database so notify::favicon fires. Use the
|
||||
* data manager's base data directory (per-user) so favicons are
|
||||
* isolated too. Passing NULL would use a shared default location. */
|
||||
char fav_dir[600];
|
||||
WebKitWebsiteDataManager *data_mgr =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr);
|
||||
if (base != NULL) {
|
||||
g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base);
|
||||
} else {
|
||||
/* Ephemeral data manager has no base dir; use a temp favicons
|
||||
* dir so the favicon DB still works in no-login mode. */
|
||||
g_snprintf(fav_dir, sizeof(fav_dir),
|
||||
"%s/sovereign_browser-favicons-ephemeral",
|
||||
g_get_tmp_dir());
|
||||
}
|
||||
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[web-context] No pubkey — cannot build per-user context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Ensure the per-user webkit data directory exists. */
|
||||
if (profile_ensure_webkit_dir(pubkey_hex) != 0) {
|
||||
g_printerr("[web-context] Failed to create webkit dir for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char webkit_dir[600];
|
||||
profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir));
|
||||
if (webkit_dir[0] == '\0') {
|
||||
g_printerr("[web-context] Failed to get webkit dir path for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the per-user data manager. base-data-directory covers
|
||||
* local-storage, indexed-db, websql, service-worker-registrations,
|
||||
* offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory
|
||||
* covers disk-cache. WebKit derives subdirs under these. */
|
||||
char data_dir[600];
|
||||
char cache_dir[600];
|
||||
g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir);
|
||||
g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir);
|
||||
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new(
|
||||
"base-data-directory", data_dir,
|
||||
"base-cache-directory", cache_dir,
|
||||
NULL);
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build a fresh context bound to this data manager. */
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The context owns the data manager now; drop our ref. */
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n",
|
||||
pubkey_hex, data_dir, cache_dir);
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_ephemeral(void) {
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral();
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built ephemeral context (no on-disk persistence)\n");
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
void web_context_teardown(void) {
|
||||
if (g_ctx == NULL) return;
|
||||
|
||||
/* Unref the context. The context releases its reference to the data
|
||||
* manager. Any remaining webviews referencing this context would
|
||||
* keep it alive — the caller must have closed them first. */
|
||||
WebKitWebContext *old = g_ctx;
|
||||
g_ctx = NULL;
|
||||
g_object_unref(old);
|
||||
|
||||
g_print("[web-context] Torn down previous context\n");
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_get(void) {
|
||||
return g_ctx;
|
||||
}
|
||||
88
src/web_context.h
Normal file
88
src/web_context.h
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* web_context.h — per-user WebKitWebContext construction/teardown
|
||||
*
|
||||
* Phase A of plans/webkit-data-isolation.md. Each Nostr identity gets
|
||||
* its own WebKitWebContext backed by a per-user WebKitWebsiteDataManager
|
||||
* rooted at ~/.sovereign_browser/profiles/<pubkey>/webkit/. This gives
|
||||
* true isolation: cookies, cache, localStorage, IndexedDB, service
|
||||
* workers, and favicons persist per-user and never cross-contaminate.
|
||||
*
|
||||
* WebKitGTK binds a WebKitWebsiteDataManager to a WebKitWebContext for
|
||||
* the context's lifetime, so per-user isolation requires building a
|
||||
* fresh context per login and tearing it down on logout/switch.
|
||||
*
|
||||
* The current context pointer is held in this module so the rest of the
|
||||
* codebase can fetch it via web_context_get() instead of
|
||||
* webkit_web_context_get_default().
|
||||
*/
|
||||
|
||||
#ifndef WEB_CONTEXT_H
|
||||
#define WEB_CONTEXT_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Build a fresh WebKitWebContext with a per-user WebKitWebsiteDataManager
|
||||
* rooted at ~/.sovereign_browser/profiles/<pubkey_hex>/webkit/.
|
||||
*
|
||||
* pubkey_hex — 64-char hex pubkey. Must be non-NULL/non-empty.
|
||||
*
|
||||
* Configures:
|
||||
* - per-user base-data-directory + base-cache-directory (WebKit derives
|
||||
* local-storage, indexed-db, websql, service-worker, offline-app-cache,
|
||||
* hsts, itp, device-id-hash-salt under these)
|
||||
* - favicon database enabled (per-user)
|
||||
* - TLS errors ignored (FIPS uses Noise IK, not TLS CAs)
|
||||
* - security manager: sovereign://, tor://, file:// registered as
|
||||
* secure; file:// also registered as local
|
||||
*
|
||||
* Stores the new context as the current context (returned by
|
||||
* web_context_get()). The caller must call web_context_teardown() on the
|
||||
* previous context first if one exists.
|
||||
*
|
||||
* Returns the new WebKitWebContext (owned by this module), or NULL on
|
||||
* failure.
|
||||
*/
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex);
|
||||
|
||||
/*
|
||||
* Build a fresh WebKitWebContext with an EPHEMERAL data manager (no
|
||||
* on-disk persistence). Used for --no-login / read-only mode where
|
||||
* there is no pubkey to key a per-user data directory on, and for the
|
||||
* logged-out state. Same security/TLS/favicon config as
|
||||
* web_context_build_for_pubkey.
|
||||
*
|
||||
* Stores the new context as the current context.
|
||||
*
|
||||
* Returns the new WebKitWebContext, or NULL on failure.
|
||||
*/
|
||||
WebKitWebContext *web_context_build_ephemeral(void);
|
||||
|
||||
/*
|
||||
* Tear down the current per-user WebKitWebContext: unref the context
|
||||
* and its data manager. After this, web_context_get() returns NULL
|
||||
* until the next build call.
|
||||
*
|
||||
* The caller MUST have already closed all webviews that reference the
|
||||
* old context (tabs + sidebar webviews) before calling this, otherwise
|
||||
* the webviews will hold dangling references. identity_teardown_web_state()
|
||||
* in main.c does this.
|
||||
*/
|
||||
void web_context_teardown(void);
|
||||
|
||||
/*
|
||||
* Return the current WebKitWebContext, or NULL if none has been built
|
||||
* (or the last one was torn down). Replaces webkit_web_context_get_default()
|
||||
* for code that wants the per-user context.
|
||||
*/
|
||||
WebKitWebContext *web_context_get(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* WEB_CONTEXT_H */
|
||||
113
src/webkit_data.c
Normal file
113
src/webkit_data.c
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* webkit_data.c — helpers to wipe WebKit website data on identity change
|
||||
*
|
||||
* See webkit_data.h for the rationale. This is Phase 0 of
|
||||
* plans/webkit-data-isolation.md: wipe the shared data manager in place on
|
||||
* logout / identity switch so the next user does not inherit the previous
|
||||
* user's cookies, cache, localStorage, service workers, etc.
|
||||
*/
|
||||
|
||||
#include "webkit_data.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
/* ── Async clear state ─────────────────────────────────────────────── *
|
||||
* webkit_website_data_manager_clear() is asynchronous. We drive a nested
|
||||
* GLib main loop until the clear callback fires (or a 5s timeout expires)
|
||||
* so callers can treat webkit_data_clear_all() as synchronous.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
gboolean done;
|
||||
gboolean timed_out;
|
||||
} clear_state_t;
|
||||
|
||||
static void on_clear_finished(GObject *source, GAsyncResult *res,
|
||||
gpointer user_data) {
|
||||
(void)source;
|
||||
(void)res;
|
||||
clear_state_t *st = (clear_state_t *)user_data;
|
||||
st->done = TRUE;
|
||||
}
|
||||
|
||||
static gboolean on_clear_timeout(gpointer user_data) {
|
||||
clear_state_t *st = (clear_state_t *)user_data;
|
||||
if (!st->done) {
|
||||
st->timed_out = TRUE;
|
||||
st->done = TRUE;
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons) {
|
||||
if (ctx == NULL) {
|
||||
ctx = webkit_web_context_get_default();
|
||||
}
|
||||
if (ctx == NULL) {
|
||||
g_printerr("[webkit-data] No WebKitWebContext to clear\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
WebKitWebsiteDataManager *dm =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
if (dm == NULL) {
|
||||
g_printerr("[webkit-data] No WebKitWebsiteDataManager on context\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Build the set of data types to clear. WEBKIT_WEBSITE_DATA_ALL covers:
|
||||
* memory cache, disk cache, offline app cache, cookies, local storage,
|
||||
* session storage, IndexedDB, WebSQL, service worker registrations,
|
||||
* HSTS, plugin data, device id/hash salt. */
|
||||
WebKitWebsiteDataTypes types = WEBKIT_WEBSITE_DATA_ALL;
|
||||
|
||||
/* Drive the async clear to completion. The clear callback and the
|
||||
* timeout fire on the default main context (where WebKit schedules
|
||||
* its async completions), so we pump the default context — NOT a
|
||||
* fresh nested context, which would never see the callback.
|
||||
*
|
||||
* We do NOT push a thread-default context because the caller (the
|
||||
* MCP request handler) is already running inside the default main
|
||||
* loop; iterating the default context here lets the clear callback
|
||||
* fire while still on the same thread. */
|
||||
clear_state_t st = { .done = FALSE, .timed_out = FALSE };
|
||||
guint timeout_id = g_timeout_add_seconds(5, on_clear_timeout, &st);
|
||||
|
||||
webkit_website_data_manager_clear(dm, types, 0, NULL,
|
||||
on_clear_finished, &st);
|
||||
|
||||
/* Pump the default context until the callback or timeout sets
|
||||
* st.done. g_main_context_iteration may return FALSE if no sources
|
||||
* are ready, so we loop on the flag, not the return value. */
|
||||
while (!st.done) {
|
||||
g_main_context_iteration(NULL, TRUE);
|
||||
}
|
||||
|
||||
if (timeout_id != 0) {
|
||||
g_source_remove(timeout_id);
|
||||
timeout_id = 0;
|
||||
}
|
||||
|
||||
if (st.timed_out) {
|
||||
g_printerr("[webkit-data] clear timed out after 5s\n");
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* Favicons live in a separate WebKitFaviconDatabase, not the data
|
||||
* manager. Clearing it is optional because it is process-global and
|
||||
* not identity-sensitive (favicons are page icons, not user data).
|
||||
* When requested, clear it so a switched user doesn't see the prior
|
||||
* user's visited-site icons in the tab strip. */
|
||||
if (clear_favicons) {
|
||||
WebKitFaviconDatabase *favdb =
|
||||
webkit_web_context_get_favicon_database(ctx);
|
||||
if (favdb != NULL) {
|
||||
webkit_favicon_database_clear(favdb);
|
||||
}
|
||||
}
|
||||
|
||||
g_print("[webkit-data] Cleared all website data (favicons=%s)\n",
|
||||
clear_favicons ? "yes" : "no");
|
||||
return 0;
|
||||
}
|
||||
54
src/webkit_data.h
Normal file
54
src/webkit_data.h
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* webkit_data.h — helpers to wipe WebKit website data on identity change
|
||||
*
|
||||
* WebKitGTK's WebKitWebsiteDataManager holds cookies, HTTP cache, localStorage,
|
||||
* IndexedDB, WebSQL, service worker registrations, offline app cache, HSTS,
|
||||
* and the favicon database for the whole process. When a user logs out or
|
||||
* switches Nostr identity, this data must be cleared so the next user does
|
||||
* not see the previous user's web session (cookies are the primary leak —
|
||||
* they identify you to web pages).
|
||||
*
|
||||
* Phase 0 of plans/webkit-data-isolation.md: a single shared data manager
|
||||
* (the default context's) is wiped in place. Phase A will replace this with
|
||||
* per-user data managers, at which point these helpers become the safety net
|
||||
* for the in-memory live-tab case.
|
||||
*/
|
||||
|
||||
#ifndef WEBKIT_DATA_H
|
||||
#define WEBKIT_DATA_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Clear ALL website data from the given WebKitWebContext's data manager:
|
||||
* cookies, disk + memory cache, local storage, session storage, IndexedDB,
|
||||
* WebSQL, service worker registrations, offline app cache, HSTS, plugin data,
|
||||
* and (optionally) favicons.
|
||||
*
|
||||
* ctx — the WebKitWebContext whose data manager to clear.
|
||||
* Pass NULL to target the default context.
|
||||
* clear_favicons — if TRUE, also clear the favicon database. The favicon
|
||||
* DB is process-global and shared across contexts; clearing
|
||||
* it drops every cached favicon, not just the current
|
||||
* site's. Pass FALSE if you only want session/cache
|
||||
* isolation (favicons are not identity-sensitive).
|
||||
*
|
||||
* This is asynchronous in WebKitGTK (the clear API takes a callback). This
|
||||
* function runs a nested GLib main loop until the clear completes (or 5s
|
||||
* elapses) so that on return the data is gone. It is safe to call from the
|
||||
* GTK main thread.
|
||||
*
|
||||
* Returns 0 on success, -1 if the context/data manager could not be resolved,
|
||||
* -2 if the clear timed out.
|
||||
*/
|
||||
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* WEBKIT_DATA_H */
|
||||
27
tests/local-site/cpu-hog.html
Normal file
27
tests/local-site/cpu-hog.html
Normal 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
tests/local-site/identity-leak-test.html
Normal file
111
tests/local-site/identity-leak-test.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Identity Leak Test</title>
|
||||
<style>
|
||||
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 2rem; max-width: 720px; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.box { border: 1px solid #ccc; padding: 1rem 1.25rem; margin: 1rem 0; border-radius: 6px; }
|
||||
.marker { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.1rem; word-break: break-all; }
|
||||
.fresh { color: #080; }
|
||||
.leaked { color: #c00; font-weight: 600; }
|
||||
button { font: inherit; padding: .35rem .8rem; margin: .25rem .25rem .25rem 0; cursor: pointer; }
|
||||
code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
|
||||
ul { margin: .5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Identity Leak Test</h1>
|
||||
|
||||
<p>This page writes a per-session marker to <code>localStorage</code> and a
|
||||
session cookie, then shows them on every load. Use it to verify that logging
|
||||
out and back in as a different identity does <strong>not</strong> leak the
|
||||
previous user's marker.</p>
|
||||
|
||||
<div class="box">
|
||||
<strong>Marker for this session:</strong>
|
||||
<div id="marker" class="marker">(none yet)</div>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<strong>Stored values seen by this page:</strong>
|
||||
<ul>
|
||||
<li><code>localStorage["sb_identity_marker"]</code> = <span id="ls-val">(none)</span></li>
|
||||
<li><code>document.cookie</code> = <span id="ck-val">(none)</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<button id="set">Write fresh marker</button>
|
||||
<button id="clear">Clear marker</button>
|
||||
<button id="reload">Reload page</button>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = "sb_identity_marker";
|
||||
|
||||
function freshMarker() {
|
||||
// Random hex string + timestamp so each "write" is distinguishable.
|
||||
var rnd = Math.random().toString(16).slice(2, 10);
|
||||
var ts = Date.now().toString(16);
|
||||
return "user-" + ts + "-" + rnd;
|
||||
}
|
||||
|
||||
function render() {
|
||||
var ls = localStorage.getItem(KEY) || "";
|
||||
var ck = "";
|
||||
document.cookie.split("; ").forEach(function (c) {
|
||||
if (c.indexOf(KEY + "=") === 0) ck = c.slice(KEY.length + 1);
|
||||
});
|
||||
|
||||
var el = document.getElementById("marker");
|
||||
var st = document.getElementById("status");
|
||||
var lsEl = document.getElementById("ls-val");
|
||||
var ckEl = document.getElementById("ck-val");
|
||||
|
||||
lsEl.textContent = ls || "(none)";
|
||||
ckEl.textContent = ck || "(none)";
|
||||
|
||||
if (ls && ck && ls === ck) {
|
||||
el.textContent = ls;
|
||||
el.className = "marker fresh";
|
||||
st.textContent = "Marker present from a previous visit on this identity.";
|
||||
} else if (ls || ck) {
|
||||
el.textContent = (ls || ck);
|
||||
el.className = "marker leaked";
|
||||
st.textContent = "PARTIAL: one of localStorage/cookie is set but not both. " +
|
||||
"If this appears right after switching identities, it is a leak.";
|
||||
} else {
|
||||
el.textContent = "(none — fresh identity)";
|
||||
el.className = "marker";
|
||||
st.textContent = "No marker found. This is what a fresh identity should see.";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("set").addEventListener("click", function () {
|
||||
var m = freshMarker();
|
||||
localStorage.setItem(KEY, m);
|
||||
// Session cookie (no max-age) scoped to this origin.
|
||||
document.cookie = KEY + "=" + m + "; path=/";
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("clear").addEventListener("click", function () {
|
||||
localStorage.removeItem(KEY);
|
||||
document.cookie = KEY + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("reload").addEventListener("click", function () {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
tests/test_bookmarks_tree.c
Normal file
142
tests/test_bookmarks_tree.c
Normal 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
tests/test_identity_isolation.sh
Normal file
134
tests/test_identity_isolation.sh
Normal 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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
301
www/bookmarks.js
301
www/bookmarks.js
@@ -1,8 +1,13 @@
|
||||
/* Bookmarks page — sovereign://bookmarks
|
||||
*
|
||||
* Fetches the bookmark directory list from sovereign://bookmarks/list
|
||||
* (JSON) and renders it. Add/delete/create-dir use the existing
|
||||
* sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints.
|
||||
* Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and
|
||||
* renders it as an expandable/collapsible tree. Folders may be nested
|
||||
* arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename
|
||||
* use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir}
|
||||
* endpoints.
|
||||
*
|
||||
* Expand/collapse state is persisted in localStorage so the user's
|
||||
* expanded folders survive page reloads.
|
||||
*/
|
||||
function sovereignGet(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
@@ -24,75 +29,255 @@ function esc(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderBookmarks(data) {
|
||||
/* ── Expand/collapse state (persisted in localStorage) ─────────────── */
|
||||
|
||||
var EXPANDED_KEY = 'sovereign_bookmarks_expanded';
|
||||
function getExpanded() {
|
||||
try {
|
||||
var raw = localStorage.getItem(EXPANDED_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch (e) { return {}; }
|
||||
}
|
||||
function setExpanded(path, isExpanded) {
|
||||
var state = getExpanded();
|
||||
if (isExpanded) state[path] = true;
|
||||
else delete state[path];
|
||||
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); }
|
||||
catch (e) { /* ignore quota errors */ }
|
||||
}
|
||||
|
||||
/* ── Tree rendering ────────────────────────────────────────────────── */
|
||||
|
||||
function renderTree(data) {
|
||||
var haveSigner = !!data.have_signer;
|
||||
document.getElementById('readonly-note').style.display =
|
||||
haveSigner ? 'none' : '';
|
||||
document.getElementById('add-form').style.display =
|
||||
haveSigner ? '' : 'none';
|
||||
|
||||
/* Populate the directory <select> for the add form. */
|
||||
var sel = document.getElementById('bm-dir');
|
||||
sel.innerHTML = '';
|
||||
(data.dirs || []).forEach(function(d) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d.name;
|
||||
opt.textContent = d.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
var c = document.getElementById('bm-list');
|
||||
var dirs = data.dirs || [];
|
||||
if (dirs.length === 0) {
|
||||
var c = document.getElementById('bm-tree');
|
||||
var tree = data.tree;
|
||||
if (!tree || !tree.children || tree.children.length === 0) {
|
||||
c.innerHTML =
|
||||
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
|
||||
'in the toolbar to save a page.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
dirs.forEach(function(dir) {
|
||||
html += '<div class="dir-header"><h3>' + esc(dir.name) +
|
||||
' (' + dir.count + ')</h3>';
|
||||
if (haveSigner && dir.name !== 'General') {
|
||||
var enc = encodeURIComponent(dir.name);
|
||||
html += ' <a class="btn btn-del" href="sovereign://bookmarks/deletedir?dir=' +
|
||||
enc + '" onclick="return confirm(\'Delete directory "' +
|
||||
esc(dir.name) + '"? Bookmarks will be moved to General.\')">Delete Dir</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (dir.count === 0) {
|
||||
html += '<p class="empty">(empty)</p>';
|
||||
} else {
|
||||
(dir.bookmarks || []).forEach(function(bm) {
|
||||
var urlEnc = encodeURIComponent(bm.url);
|
||||
var dirEnc = encodeURIComponent(dir.name);
|
||||
var title = bm.title && bm.title.length ? bm.title : '(untitled)';
|
||||
html += '<div class="bm"><div>';
|
||||
html += '<div class="bm-title">' + esc(title) + '</div>';
|
||||
html += '<div><a class="bm-url" href="' + esc(bm.url) + '">' +
|
||||
esc(bm.url) + '</a></div>';
|
||||
html += '<div class="bm-meta">Added: ' + esc(bm.added) + '</div></div>';
|
||||
html += '<div class="actions">';
|
||||
html += '<a class="btn" href="' + esc(bm.url) + '">Open</a>';
|
||||
if (haveSigner) {
|
||||
html += ' <a class="btn btn-del" href="sovereign://bookmarks/delete?dir=' +
|
||||
dirEnc + '&url=' + urlEnc +
|
||||
'" onclick="return confirm(\'Delete this bookmark?\')">Delete</a>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
});
|
||||
}
|
||||
c.innerHTML = '';
|
||||
(tree.children || []).forEach(function(child) {
|
||||
c.appendChild(renderNode(child, haveSigner, 0));
|
||||
});
|
||||
c.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderNode(node, haveSigner, depth) {
|
||||
/* A folder row + its bookmark list + its children (recursive). */
|
||||
var expandedState = getExpanded();
|
||||
var isExpanded = !!expandedState[node.path];
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'tree-node';
|
||||
|
||||
/* Folder row. */
|
||||
var row = document.createElement('div');
|
||||
row.className = 'folder-row';
|
||||
|
||||
var caret = document.createElement('span');
|
||||
caret.className = 'caret' + (isExpanded ? ' expanded' : '');
|
||||
caret.textContent = '\u25B6'; /* right-pointing triangle */
|
||||
caret.onclick = function() {
|
||||
var nowExpanded = caret.classList.toggle('expanded');
|
||||
childList.style.display = nowExpanded ? '' : 'none';
|
||||
setExpanded(node.path, nowExpanded);
|
||||
};
|
||||
row.appendChild(caret);
|
||||
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'folder-icon';
|
||||
icon.textContent = '\u{1F4C1}'; /* folder emoji */
|
||||
row.appendChild(icon);
|
||||
|
||||
var name = document.createElement('span');
|
||||
name.className = 'folder-name';
|
||||
name.textContent = node.name + ' (' + node.count + ')';
|
||||
row.appendChild(name);
|
||||
|
||||
/* Per-folder actions. */
|
||||
var actions = document.createElement('span');
|
||||
actions.className = 'folder-actions';
|
||||
if (haveSigner) {
|
||||
var addHere = document.createElement('a');
|
||||
addHere.className = 'btn';
|
||||
addHere.href = '#';
|
||||
addHere.textContent = 'Add Here';
|
||||
addHere.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('bm-path').value = node.path;
|
||||
document.getElementById('bm-url').focus();
|
||||
};
|
||||
actions.appendChild(addHere);
|
||||
|
||||
var newSub = document.createElement('a');
|
||||
newSub.className = 'btn';
|
||||
newSub.href = '#';
|
||||
newSub.textContent = 'New Subfolder';
|
||||
newSub.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var sub = prompt('New subfolder name under "' + node.path + '/":');
|
||||
if (sub && sub.trim()) {
|
||||
var newPath = node.path + '/' + sub.trim();
|
||||
location.href = 'sovereign://bookmarks/createdir?name=' +
|
||||
encodeURIComponent(newPath);
|
||||
}
|
||||
};
|
||||
actions.appendChild(newSub);
|
||||
|
||||
if (node.path !== 'General' && node.path !== 'Bookmarks Bar') {
|
||||
var rename = document.createElement('a');
|
||||
rename.className = 'btn';
|
||||
rename.href = '#';
|
||||
rename.textContent = 'Rename';
|
||||
rename.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var newName = prompt('Rename folder "' + node.path + '" to:',
|
||||
node.path);
|
||||
if (newName && newName.trim() && newName.trim() !== node.path) {
|
||||
location.href = 'sovereign://bookmarks/renamedir?old=' +
|
||||
encodeURIComponent(node.path) + '&new=' +
|
||||
encodeURIComponent(newName.trim());
|
||||
}
|
||||
};
|
||||
actions.appendChild(rename);
|
||||
|
||||
var del = document.createElement('a');
|
||||
del.className = 'btn btn-del';
|
||||
del.href = '#';
|
||||
del.textContent = 'Delete';
|
||||
del.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Delete folder "' + node.path + '"? ' +
|
||||
'Bookmarks will be moved to General.')) {
|
||||
location.href = 'sovereign://bookmarks/deletedir?dir=' +
|
||||
encodeURIComponent(node.path);
|
||||
}
|
||||
};
|
||||
actions.appendChild(del);
|
||||
}
|
||||
}
|
||||
row.appendChild(actions);
|
||||
|
||||
wrap.appendChild(row);
|
||||
|
||||
/* Child list: bookmarks + subfolders. */
|
||||
var childList = document.createElement('div');
|
||||
childList.className = 'tree-children';
|
||||
childList.style.display = isExpanded ? '' : 'none';
|
||||
childList.style.marginLeft = '20px';
|
||||
|
||||
/* Bookmarks in this folder. */
|
||||
(node.bookmarks || []).forEach(function(bm) {
|
||||
childList.appendChild(renderBookmark(bm, node, haveSigner));
|
||||
});
|
||||
|
||||
/* Subfolders. */
|
||||
(node.children || []).forEach(function(child) {
|
||||
childList.appendChild(renderNode(child, haveSigner, depth + 1));
|
||||
});
|
||||
|
||||
wrap.appendChild(childList);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderBookmark(bm, folder, haveSigner) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'bm';
|
||||
|
||||
var info = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.className = 'bm-title';
|
||||
title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)';
|
||||
info.appendChild(title);
|
||||
|
||||
var urlLink = document.createElement('div');
|
||||
var a = document.createElement('a');
|
||||
a.className = 'bm-url';
|
||||
a.href = bm.url;
|
||||
a.textContent = bm.url;
|
||||
urlLink.appendChild(a);
|
||||
info.appendChild(urlLink);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'bm-meta';
|
||||
meta.textContent = 'Added: ' + bm.added;
|
||||
info.appendChild(meta);
|
||||
|
||||
row.appendChild(info);
|
||||
|
||||
if (haveSigner) {
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
|
||||
var editBtn = document.createElement('a');
|
||||
editBtn.className = 'btn';
|
||||
editBtn.href = '#';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var curTitle = (bm.title && bm.title.length) ? bm.title : '';
|
||||
var newTitle = prompt('Edit bookmark name:', curTitle);
|
||||
if (newTitle === null) return;
|
||||
var t = newTitle.trim();
|
||||
if (t === curTitle.trim()) return;
|
||||
location.href = 'sovereign://bookmarks/rename?dir=' +
|
||||
encodeURIComponent(folder.path) + '&url=' +
|
||||
encodeURIComponent(bm.url) + '&title=' +
|
||||
encodeURIComponent(t);
|
||||
};
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
var moveBtn = document.createElement('a');
|
||||
moveBtn.className = 'btn';
|
||||
moveBtn.href = '#';
|
||||
moveBtn.textContent = 'Move';
|
||||
moveBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var dest = prompt('Move bookmark to folder path:', folder.path);
|
||||
if (dest && dest.trim() && dest.trim() !== folder.path) {
|
||||
location.href = 'sovereign://bookmarks/move?from=' +
|
||||
encodeURIComponent(folder.path) + '&to=' +
|
||||
encodeURIComponent(dest.trim()) + '&url=' +
|
||||
encodeURIComponent(bm.url);
|
||||
}
|
||||
};
|
||||
actions.appendChild(moveBtn);
|
||||
|
||||
var delBtn = document.createElement('a');
|
||||
delBtn.className = 'btn btn-del';
|
||||
delBtn.href = '#';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Delete this bookmark?')) {
|
||||
location.href = 'sovereign://bookmarks/delete?dir=' +
|
||||
encodeURIComponent(folder.path) + '&url=' +
|
||||
encodeURIComponent(bm.url);
|
||||
}
|
||||
};
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ── Fetch + actions ───────────────────────────────────────────────── */
|
||||
|
||||
function fetchBookmarks() {
|
||||
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
|
||||
.then(function(data) { renderBookmarks(data); })
|
||||
.then(function(data) { renderTree(data); })
|
||||
.catch(function(e) {
|
||||
document.getElementById('bm-list').innerHTML =
|
||||
document.getElementById('bm-tree').innerHTML =
|
||||
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
|
||||
});
|
||||
}
|
||||
@@ -100,15 +285,15 @@ function fetchBookmarks() {
|
||||
function addBookmark() {
|
||||
var url = encodeURIComponent(document.getElementById('bm-url').value);
|
||||
var title = encodeURIComponent(document.getElementById('bm-title').value);
|
||||
var dir = encodeURIComponent(document.getElementById('bm-dir').value);
|
||||
var path = encodeURIComponent(document.getElementById('bm-path').value);
|
||||
if (!url) { alert('URL is required'); return; }
|
||||
location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url +
|
||||
location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url +
|
||||
'&title=' + title;
|
||||
}
|
||||
|
||||
function createDir() {
|
||||
var name = encodeURIComponent(document.getElementById('new-dir').value);
|
||||
if (!name) { alert('Directory name is required'); return; }
|
||||
var name = encodeURIComponent(document.getElementById('new-path').value);
|
||||
if (!name) { alert('Folder path is required'); return; }
|
||||
location.href = 'sovereign://bookmarks/createdir?name=' + name;
|
||||
}
|
||||
|
||||
|
||||
193
www/fips.css
Normal file
193
www/fips.css
Normal file
@@ -0,0 +1,193 @@
|
||||
/* FIPS management page — sovereign://fips
|
||||
* Imports the shared sovereign:// base theme, then adds FIPS-specific
|
||||
* layout for status cards, the peers table, and the add-peer form. */
|
||||
@import url("sovereign://sovereign-base.css");
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
|
||||
.card .actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.err-text {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* State indicator dot. */
|
||||
.state-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
background: var(--muted);
|
||||
}
|
||||
.state-dot.ready { background: var(--primary); }
|
||||
.state-dot.failed { background: var(--accent); }
|
||||
.state-dot.starting,
|
||||
.state-dot.stopping,
|
||||
.state-dot.bootstrapping,
|
||||
.state-dot.discovering,
|
||||
.state-dot.attaching { background: var(--accent); animation: pulse 1s infinite; }
|
||||
|
||||
/* Peers table. */
|
||||
#peers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
#peers-table th,
|
||||
#peers-table td {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
#peers-table th {
|
||||
color: var(--muted);
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
#peers-table td.mono { word-break: break-all; }
|
||||
|
||||
.peer-state-connected { color: var(--primary); }
|
||||
.peer-state-connecting,
|
||||
.peer-state-disconnected { color: var(--muted); }
|
||||
.peer-state-failed { color: var(--accent); }
|
||||
|
||||
#refresh-btn { margin-left: 12px; }
|
||||
|
||||
/* Tab navigation. */
|
||||
.tabs {
|
||||
display: flex; gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: var(--bg); color: var(--muted);
|
||||
border: 1px solid transparent; border-bottom: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 6px 16px; cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; font-weight: bold;
|
||||
margin-left: 0; min-width: auto;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--primary); }
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-color: var(--border);
|
||||
border-bottom: 1px solid var(--bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* Known-nodes / tree-peers tables. */
|
||||
#nodes-table, #tree-peers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
#nodes-table th, #nodes-table td,
|
||||
#tree-peers-table th, #tree-peers-table td {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
#nodes-table th, #tree-peers-table th {
|
||||
color: var(--muted);
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
#nodes-table td.mono, #tree-peers-table td.mono { word-break: break-all; }
|
||||
|
||||
/* Direct-peer badge in the known-nodes table. */
|
||||
.peer-badge {
|
||||
display: inline-block; width: 8px; height: 8px;
|
||||
border-radius: 50%; background: var(--primary);
|
||||
}
|
||||
.peer-badge.indirect { background: var(--muted); }
|
||||
|
||||
/* Staleness coloring for last-seen. */
|
||||
.last-seen-fresh { color: var(--primary); }
|
||||
.last-seen-stale { color: var(--muted); }
|
||||
.last-seen-old { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* Tree role tags. */
|
||||
.tree-role { font-size: 11px; padding: 1px 6px; border-radius: 3px;
|
||||
border: 1px solid var(--border); }
|
||||
.tree-role.root { color: var(--accent); border-color: var(--accent); }
|
||||
.tree-role.parent { color: var(--primary); }
|
||||
.tree-role.child { color: var(--muted); }
|
||||
|
||||
/* Directory tab — compact one-line rows, wider table, smaller font. */
|
||||
body.wide { max-width: 1400px; }
|
||||
#tab-directory { max-width: 100%; }
|
||||
#dir-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
#dir-table th, #dir-table td {
|
||||
text-align: left;
|
||||
padding: 3px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#dir-table th {
|
||||
color: var(--muted);
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
/* Column widths: badge | npub | endpoint | connect */
|
||||
#dir-table th:nth-child(1), #dir-table td:nth-child(1) { width: 20px; }
|
||||
#dir-table th:nth-child(3), #dir-table td:nth-child(3) { width: 35%; }
|
||||
#dir-table th:nth-child(4), #dir-table td:nth-child(4) { width: 80px; }
|
||||
#dir-table td.mono {
|
||||
font-family: var(--font);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Pinned "this is you" row — highlighted border. */
|
||||
#dir-table tr.dir-ours-row {
|
||||
border: 2px solid var(--accent);
|
||||
background: rgba(255, 0, 0, 0.05);
|
||||
}
|
||||
#dir-table tr.dir-ours-row td {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
.dir-ours-tag {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Connect as clickable text, not a button. */
|
||||
.dir-connect-link {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dir-connect-link:hover { text-decoration: none; }
|
||||
160
www/fips.html
Normal file
160
www/fips.html
Normal file
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FIPS Mesh Network</title>
|
||||
<link rel="stylesheet" href="sovereign://fips.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>FIPS Mesh Network <button class="btn" id="refresh-btn">Refresh</button></h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="status">Status</button>
|
||||
<button class="tab-btn" data-tab="peers">Peers</button>
|
||||
<button class="tab-btn" data-tab="network">Network</button>
|
||||
<button class="tab-btn" data-tab="tree">Tree</button>
|
||||
<button class="tab-btn" data-tab="directory">Directory</button>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status"></div>
|
||||
|
||||
<!-- Status tab -->
|
||||
<div class="tab-panel active" id="tab-status">
|
||||
<h2>Status</h2>
|
||||
<div class="card" id="fips-status">
|
||||
<div class="info-row"><span>State</span><span id="fips-state">—</span></div>
|
||||
<div class="info-row"><span>Node</span><span id="fips-node" class="mono">—</span></div>
|
||||
<div class="info-row"><span>TUN</span><span id="fips-tun">—</span></div>
|
||||
<div class="info-row"><span>Peers</span><span id="fips-peers">—</span></div>
|
||||
<div class="info-row"><span>Tree</span><span id="fips-tree">—</span></div>
|
||||
<div class="info-row"><span>Ownership</span><span id="fips-ownership">—</span></div>
|
||||
<div class="info-row"><span>Control</span><span id="fips-control" class="mono">—</span></div>
|
||||
<div class="info-row" id="fips-error-row" style="display:none">
|
||||
<span>Error</span><span id="fips-error" class="err-text"></span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="restart-btn">Restart Service</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Tor Status <span class="note">(read-only)</span></h2>
|
||||
<div class="card" id="tor-status">
|
||||
<div class="info-row"><span>State</span><span id="tor-state">—</span></div>
|
||||
<div class="info-row"><span>Bootstrap</span><span id="tor-bootstrap">—</span></div>
|
||||
<div class="info-row"><span>SOCKS</span><span id="tor-socks" class="mono">—</span></div>
|
||||
<div class="info-row"><span>Ownership</span><span id="tor-ownership">—</span></div>
|
||||
<div class="info-row" id="tor-error-row" style="display:none">
|
||||
<span>Error</span><span id="tor-error" class="err-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peers tab -->
|
||||
<div class="tab-panel" id="tab-peers">
|
||||
<h2>Peers</h2>
|
||||
<div id="peers-container">
|
||||
<p class="empty" id="peers-empty">Loading peers…</p>
|
||||
<table id="peers-table" style="display:none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>npub</th><th>address</th><th>transport</th><th>state</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Add Peer</h2>
|
||||
<div class="card">
|
||||
<div class="form">
|
||||
<input type="text" id="peer-npub" placeholder="npub1..." size="50">
|
||||
<input type="text" id="peer-address" placeholder="host:port" size="30">
|
||||
<select id="peer-transport">
|
||||
<option value="udp">udp</option>
|
||||
<option value="tcp">tcp</option>
|
||||
</select>
|
||||
<button class="btn" id="connect-btn">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network tab -->
|
||||
<div class="tab-panel" id="tab-network">
|
||||
<h2>Mesh Summary</h2>
|
||||
<div class="card" id="mesh-summary">
|
||||
<div class="info-row"><span>Known nodes</span><span id="mesh-known">—</span></div>
|
||||
<div class="info-row"><span>Direct peers</span><span id="mesh-direct">—</span></div>
|
||||
<div class="info-row"><span>Tree depth</span><span id="mesh-depth">—</span></div>
|
||||
<div class="info-row"><span>Root</span><span id="mesh-root" class="mono">—</span></div>
|
||||
<div class="info-row"><span>Our parent</span><span id="mesh-parent">—</span></div>
|
||||
<div class="info-row" id="mesh-error-row" style="display:none">
|
||||
<span>Error</span><span id="mesh-error" class="err-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Known Nodes <span class="note" id="nodes-count-label"></span></h2>
|
||||
<div id="nodes-container">
|
||||
<p class="empty" id="nodes-empty">Loading network…</p>
|
||||
<table id="nodes-table" style="display:none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>npub</th><th>name</th><th>ipv6</th><th>last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="nodes-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory tab -->
|
||||
<div class="tab-panel" id="tab-directory">
|
||||
<h2>FIPS Node Directory <span class="note" id="dir-count-label"></span></h2>
|
||||
<p class="note">All nodes that have published a FIPS service advert (Kind 37195) to the Nostr advert relays. This is the global network directory, like join.fips.network.</p>
|
||||
<div id="dir-container">
|
||||
<p class="empty" id="dir-empty">Click to load the directory from Nostr relays…</p>
|
||||
<table id="dir-table" style="display:none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>npub</th><th>endpoint</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dir-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions" id="dir-actions" style="display:none">
|
||||
<button class="btn" id="dir-refresh-btn">Reload Directory</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree tab -->
|
||||
<div class="tab-panel" id="tab-tree">
|
||||
<h2>Spanning Tree</h2>
|
||||
<div class="card" id="tree-summary">
|
||||
<div class="info-row"><span>Root</span><span id="tree-root" class="mono">—</span></div>
|
||||
<div class="info-row"><span>Is root</span><span id="tree-is-root">—</span></div>
|
||||
<div class="info-row"><span>Our depth</span><span id="tree-depth">—</span></div>
|
||||
<div class="info-row"><span>Our parent</span><span id="tree-parent">—</span></div>
|
||||
<div class="info-row"><span>Our node addr</span><span id="tree-node-addr" class="mono">—</span></div>
|
||||
<div class="info-row" id="tree-error-row" style="display:none">
|
||||
<span>Error</span><span id="tree-error" class="err-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Tree Peers <span class="note">(1-hop neighbors)</span></h2>
|
||||
<div id="tree-peers-container">
|
||||
<p class="empty" id="tree-peers-empty">Loading tree…</p>
|
||||
<table id="tree-peers-table" style="display:none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>node addr</th><th>name</th><th>depth</th><th>distance</th><th>role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tree-peers-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="sovereign://fips.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
564
www/fips.js
Normal file
564
www/fips.js
Normal file
@@ -0,0 +1,564 @@
|
||||
/* FIPS management page — sovereign://fips
|
||||
*
|
||||
* Tabs: Status | Peers | Network | Tree
|
||||
*
|
||||
* - Status: FIPS + Tor status cards (sovereign://fips/status)
|
||||
* - Peers: direct peers table + add-peer form (sovereign://fips/peers)
|
||||
* - Network: mesh summary + known-nodes table (sovereign://fips/network)
|
||||
* - Tree: spanning tree view (sovereign://fips/tree)
|
||||
*
|
||||
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
|
||||
* handler does not reliably support fetch() for sovereign:// URLs.
|
||||
*/
|
||||
|
||||
function sovereignGet(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var x = new XMLHttpRequest();
|
||||
x.open('GET', url, true);
|
||||
x.onreadystatechange = function() {
|
||||
if (x.readyState !== 4) return;
|
||||
try { resolve(JSON.parse(x.responseText)); }
|
||||
catch (e) { reject(e); }
|
||||
};
|
||||
x.onerror = function() { reject(new Error('XHR error')); };
|
||||
x.send();
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showMsg(text, isErr) {
|
||||
var el = document.getElementById('status-msg');
|
||||
el.textContent = text || '';
|
||||
el.className = 'status show ' + (isErr ? 'err' : 'ok');
|
||||
if (!text) el.className = 'status';
|
||||
}
|
||||
|
||||
function stateDotClass(state) {
|
||||
return 'state-dot ' + (state || '');
|
||||
}
|
||||
|
||||
/* Global: our own FIPS node npub (from show_status), used to pin our
|
||||
* node in the Directory tab. */
|
||||
var gOurNpub = '';
|
||||
|
||||
/* ── Tab switching ──────────────────────────────────────────────── */
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(function(panel) {
|
||||
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
|
||||
});
|
||||
/* Widen the page for the directory tab (long npub list needs width). */
|
||||
document.body.classList.toggle('wide', tabName === 'directory');
|
||||
/* Load tab data on demand. */
|
||||
if (tabName === 'network') fetchNetwork();
|
||||
if (tabName === 'tree') fetchTree();
|
||||
if (tabName === 'directory') fetchDirectory();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
|
||||
});
|
||||
|
||||
/* ── Status tab ─────────────────────────────────────────────────── */
|
||||
|
||||
function renderFipsStatus(data) {
|
||||
var f = data.fips || {};
|
||||
var state = f.state || 'disabled';
|
||||
/* Store our npub for the Directory tab pinning. */
|
||||
gOurNpub = f.node_npub || '';
|
||||
document.getElementById('fips-state').innerHTML =
|
||||
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
|
||||
document.getElementById('fips-node').textContent = f.node_npub || '—';
|
||||
var tun = '—';
|
||||
if (f.tun_name && f.tun_name.length) {
|
||||
tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)');
|
||||
}
|
||||
document.getElementById('fips-tun').textContent = tun;
|
||||
document.getElementById('fips-peers').textContent =
|
||||
(f.peer_count != null ? f.peer_count : '—') + ' connected';
|
||||
document.getElementById('fips-tree').textContent = f.tree_state || '—';
|
||||
document.getElementById('fips-ownership').textContent =
|
||||
f.ownership || '—';
|
||||
document.getElementById('fips-control').textContent =
|
||||
f.control_socket || '—';
|
||||
|
||||
var errRow = document.getElementById('fips-error-row');
|
||||
var errEl = document.getElementById('fips-error');
|
||||
if (f.error && f.error.length) {
|
||||
errEl.textContent = f.error;
|
||||
errRow.style.display = '';
|
||||
} else {
|
||||
errRow.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTorStatus(data) {
|
||||
var t = data.tor || {};
|
||||
var state = t.state || 'disabled';
|
||||
document.getElementById('tor-state').innerHTML =
|
||||
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
|
||||
document.getElementById('tor-bootstrap').textContent =
|
||||
(t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%';
|
||||
document.getElementById('tor-socks').textContent =
|
||||
t.socks_endpoint || '—';
|
||||
document.getElementById('tor-ownership').textContent =
|
||||
t.ownership || '—';
|
||||
|
||||
var errRow = document.getElementById('tor-error-row');
|
||||
var errEl = document.getElementById('tor-error');
|
||||
if (t.error && t.error.length) {
|
||||
errEl.textContent = t.error;
|
||||
errRow.style.display = '';
|
||||
} else {
|
||||
errRow.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
return sovereignGet('sovereign://fips/status?_=' + Date.now())
|
||||
.then(function(data) {
|
||||
renderFipsStatus(data);
|
||||
renderTorStatus(data);
|
||||
})
|
||||
.catch(function(e) {
|
||||
showMsg('Failed to load status: ' + e.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Peers tab ──────────────────────────────────────────────────── */
|
||||
|
||||
function renderPeers(data) {
|
||||
var body = document.getElementById('peers-body');
|
||||
var table = document.getElementById('peers-table');
|
||||
var empty = document.getElementById('peers-empty');
|
||||
|
||||
var peers = data.peers || [];
|
||||
if (!peers.length) {
|
||||
table.style.display = 'none';
|
||||
empty.style.display = '';
|
||||
empty.textContent = data.error
|
||||
? 'No peers (' + data.error + ')'
|
||||
: 'No peers connected.';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
table.style.display = '';
|
||||
var html = '';
|
||||
peers.forEach(function(p) {
|
||||
var npub = p.npub || p.id || '';
|
||||
var addr = p.transport_addr || p.address || p.addr || '';
|
||||
var transport = p.transport_type || p.transport || '';
|
||||
var state = p.connectivity || p.state || p.status || '';
|
||||
var label = p.display_name || '';
|
||||
html += '<tr>';
|
||||
html += '<td class="mono">' + esc(npub) +
|
||||
(label ? '<br><span class="note">' + esc(label) + '</span>' : '') +
|
||||
'</td>';
|
||||
html += '<td class="mono">' + esc(addr) + '</td>';
|
||||
html += '<td>' + esc(transport) + '</td>';
|
||||
html += '<td class="peer-state-' + esc(state) + '">' + esc(state) + '</td>';
|
||||
html += '<td><button class="btn btn-del" data-npub="' + esc(npub) +
|
||||
'" onclick="disconnectPeer(\'' + esc(npub).replace(/'/g, "\\'") +
|
||||
'\')">✕</button></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function fetchPeers() {
|
||||
return sovereignGet('sovereign://fips/peers?_=' + Date.now())
|
||||
.then(function(data) { renderPeers(data); })
|
||||
.catch(function(e) {
|
||||
document.getElementById('peers-empty').textContent =
|
||||
'Failed to load peers: ' + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function connectPeer() {
|
||||
var npub = document.getElementById('peer-npub').value.trim();
|
||||
var address = document.getElementById('peer-address').value.trim();
|
||||
var transport = document.getElementById('peer-transport').value;
|
||||
if (!npub || !address) {
|
||||
showMsg('npub and address are required', true);
|
||||
return;
|
||||
}
|
||||
var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) +
|
||||
'&address=' + encodeURIComponent(address) +
|
||||
'&transport=' + encodeURIComponent(transport);
|
||||
sovereignGet(url)
|
||||
.then(function(d) {
|
||||
if (d.error) {
|
||||
showMsg('Connect failed: ' + d.error, true);
|
||||
} else {
|
||||
showMsg('Peer added');
|
||||
document.getElementById('peer-npub').value = '';
|
||||
document.getElementById('peer-address').value = '';
|
||||
fetchPeers();
|
||||
}
|
||||
})
|
||||
.catch(function(e) { showMsg('Connect failed: ' + e.message, true); });
|
||||
}
|
||||
|
||||
function disconnectPeer(npub) {
|
||||
if (!npub) return;
|
||||
if (!confirm('Disconnect peer ' + npub + '?')) return;
|
||||
var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub);
|
||||
sovereignGet(url)
|
||||
.then(function(d) {
|
||||
if (d.error) {
|
||||
showMsg('Disconnect failed: ' + d.error, true);
|
||||
} else {
|
||||
showMsg('Peer disconnected');
|
||||
fetchPeers();
|
||||
}
|
||||
})
|
||||
.catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); });
|
||||
}
|
||||
|
||||
function restartService() {
|
||||
if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return;
|
||||
showMsg('Restarting FIPS service…');
|
||||
sovereignGet('sovereign://fips/restart')
|
||||
.then(function(d) {
|
||||
if (d.error) showMsg('Restart failed: ' + d.error, true);
|
||||
else showMsg('Restart initiated');
|
||||
})
|
||||
.catch(function(e) { showMsg('Restart failed: ' + e.message, true); });
|
||||
}
|
||||
|
||||
/* ── Network tab ────────────────────────────────────────────────── */
|
||||
|
||||
function formatAge(ageMs) {
|
||||
if (ageMs == null) return '—';
|
||||
var s = Math.floor(ageMs / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ago';
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h ago';
|
||||
return Math.floor(h / 24) + 'd ago';
|
||||
}
|
||||
|
||||
function ageClass(ageMs) {
|
||||
if (ageMs == null) return '';
|
||||
if (ageMs < 10000) return 'last-seen-fresh';
|
||||
if (ageMs < 60000) return 'last-seen-stale';
|
||||
return 'last-seen-old';
|
||||
}
|
||||
|
||||
function renderNetwork(data) {
|
||||
var s = data.summary || {};
|
||||
var nodes = data.nodes || [];
|
||||
|
||||
/* Mesh summary. */
|
||||
document.getElementById('mesh-known').textContent =
|
||||
(s.known_nodes != null ? s.known_nodes : '—') +
|
||||
(s.max_cache_entries != null ? ' / ' + s.max_cache_entries : '');
|
||||
document.getElementById('mesh-direct').textContent =
|
||||
(data.peer_count != null ? data.peer_count : '—');
|
||||
document.getElementById('mesh-depth').textContent =
|
||||
(s.tree_depth != null ? s.tree_depth : '—');
|
||||
document.getElementById('mesh-root').textContent =
|
||||
s.root_npub ? esc(s.root_npub) : '—';
|
||||
document.getElementById('mesh-parent').textContent =
|
||||
s.parent_display_name ? esc(s.parent_display_name) :
|
||||
(s.is_root ? '(this node is root)' : '—');
|
||||
|
||||
var errRow = document.getElementById('mesh-error-row');
|
||||
var errEl = document.getElementById('mesh-error');
|
||||
if (data.error) {
|
||||
errEl.textContent = data.error;
|
||||
errRow.style.display = '';
|
||||
} else {
|
||||
errRow.style.display = 'none';
|
||||
}
|
||||
|
||||
/* Known nodes table. */
|
||||
var body = document.getElementById('nodes-body');
|
||||
var table = document.getElementById('nodes-table');
|
||||
var empty = document.getElementById('nodes-empty');
|
||||
var countLabel = document.getElementById('nodes-count-label');
|
||||
|
||||
countLabel.textContent = '(' + nodes.length + ' nodes)';
|
||||
|
||||
if (!nodes.length) {
|
||||
table.style.display = 'none';
|
||||
empty.style.display = '';
|
||||
empty.textContent = data.error
|
||||
? 'Network unavailable: ' + data.error
|
||||
: 'No known nodes (FIPS may not be ready).';
|
||||
return;
|
||||
}
|
||||
|
||||
/* Sort by last_seen descending (most recent first). */
|
||||
nodes.sort(function(a, b) {
|
||||
var aT = a.last_seen_ms || 0;
|
||||
var bT = b.last_seen_ms || 0;
|
||||
return bT - aT;
|
||||
});
|
||||
|
||||
empty.style.display = 'none';
|
||||
table.style.display = '';
|
||||
var html = '';
|
||||
nodes.forEach(function(n) {
|
||||
var npub = n.npub || '';
|
||||
var name = n.display_name || '—';
|
||||
var ipv6 = n.ipv6_addr || '';
|
||||
var age = n.age_ms != null ? n.age_ms : null;
|
||||
var direct = n.is_direct_peer;
|
||||
var badge = '<span class="peer-badge' +
|
||||
(direct ? '' : ' indirect') + '" title="' +
|
||||
(direct ? 'direct peer' : 'discovery only') + '"></span>';
|
||||
html += '<tr>';
|
||||
html += '<td>' + badge + '</td>';
|
||||
html += '<td class="mono">' + esc(npub) + '</td>';
|
||||
html += '<td>' + esc(name) + '</td>';
|
||||
html += '<td class="mono">' + esc(ipv6) + '</td>';
|
||||
html += '<td class="' + ageClass(age) + '">' + formatAge(age) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function fetchNetwork() {
|
||||
return sovereignGet('sovereign://fips/network?_=' + Date.now())
|
||||
.then(function(data) { renderNetwork(data); })
|
||||
.catch(function(e) {
|
||||
document.getElementById('nodes-empty').textContent =
|
||||
'Failed to load network: ' + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Tree tab ───────────────────────────────────────────────────── */
|
||||
|
||||
function renderTree(data) {
|
||||
var t = data.tree || {};
|
||||
var errRow = document.getElementById('tree-error-row');
|
||||
var errEl = document.getElementById('tree-error');
|
||||
|
||||
if (data.error) {
|
||||
errEl.textContent = data.error;
|
||||
errRow.style.display = '';
|
||||
document.getElementById('tree-peers-empty').textContent =
|
||||
'Tree unavailable: ' + data.error;
|
||||
document.getElementById('tree-peers-table').style.display = 'none';
|
||||
/* Clear summary. */
|
||||
['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr']
|
||||
.forEach(function(id) { document.getElementById(id).textContent = '—'; });
|
||||
return;
|
||||
}
|
||||
errRow.style.display = 'none';
|
||||
|
||||
document.getElementById('tree-root').textContent =
|
||||
t.root_npub || t.root || '—';
|
||||
document.getElementById('tree-is-root').textContent =
|
||||
t.is_root ? 'yes' : 'no';
|
||||
document.getElementById('tree-depth').textContent =
|
||||
(t.depth != null ? t.depth : '—');
|
||||
document.getElementById('tree-parent').textContent =
|
||||
t.parent_display_name || t.parent || '—';
|
||||
document.getElementById('tree-node-addr').textContent =
|
||||
t.my_node_addr || '—';
|
||||
|
||||
/* Tree peers (1-hop neighbors). */
|
||||
var body = document.getElementById('tree-peers-body');
|
||||
var table = document.getElementById('tree-peers-table');
|
||||
var empty = document.getElementById('tree-peers-empty');
|
||||
var peers = t.peers || [];
|
||||
|
||||
if (!peers.length) {
|
||||
table.style.display = 'none';
|
||||
empty.style.display = '';
|
||||
empty.textContent = 'No tree peers.';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
table.style.display = '';
|
||||
var myAddr = t.my_node_addr || '';
|
||||
var parentAddr = t.parent || '';
|
||||
var html = '';
|
||||
peers.forEach(function(p) {
|
||||
var addr = p.node_addr || '';
|
||||
var name = p.display_name || '—';
|
||||
var depth = p.depth != null ? p.depth : '—';
|
||||
var dist = p.distance_to_us != null ? p.distance_to_us : '—';
|
||||
var role = '';
|
||||
if (addr === parentAddr && !t.is_root) {
|
||||
role = '<span class="tree-role parent">parent</span>';
|
||||
} else if (addr === t.root) {
|
||||
role = '<span class="tree-role root">root</span>';
|
||||
} else {
|
||||
role = '<span class="tree-role child">peer</span>';
|
||||
}
|
||||
html += '<tr>';
|
||||
html += '<td class="mono">' + esc(addr) + '</td>';
|
||||
html += '<td>' + esc(name) + '</td>';
|
||||
html += '<td>' + esc(depth) + '</td>';
|
||||
html += '<td>' + esc(dist) + '</td>';
|
||||
html += '<td>' + role + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function fetchTree() {
|
||||
return sovereignGet('sovereign://fips/tree?_=' + Date.now())
|
||||
.then(function(data) { renderTree(data); })
|
||||
.catch(function(e) {
|
||||
document.getElementById('tree-peers-empty').textContent =
|
||||
'Failed to load tree: ' + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Directory tab ───────────────────────────────────────────────── */
|
||||
|
||||
function renderDirectory(data) {
|
||||
var body = document.getElementById('dir-body');
|
||||
var table = document.getElementById('dir-table');
|
||||
var empty = document.getElementById('dir-empty');
|
||||
var countLabel = document.getElementById('dir-count-label');
|
||||
var actions = document.getElementById('dir-actions');
|
||||
var nodes = data.nodes || [];
|
||||
|
||||
countLabel.textContent = '(' + nodes.length + ' nodes)';
|
||||
|
||||
if (!nodes.length) {
|
||||
table.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
empty.style.display = '';
|
||||
empty.textContent = data.error
|
||||
? 'Directory unavailable: ' + data.error
|
||||
: 'No advertised nodes found on the advert relays.';
|
||||
return;
|
||||
}
|
||||
|
||||
/* Sort: our own node first (pinned), then by created_at descending. */
|
||||
nodes.sort(function(a, b) {
|
||||
var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0;
|
||||
var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0;
|
||||
if (aOurs !== bOurs) return bOurs - aOurs;
|
||||
var aT = a.created_at || 0;
|
||||
var bT = b.created_at || 0;
|
||||
return bT - aT;
|
||||
});
|
||||
|
||||
empty.style.display = 'none';
|
||||
table.style.display = '';
|
||||
actions.style.display = '';
|
||||
var html = '';
|
||||
nodes.forEach(function(n) {
|
||||
var npub = n.npub || '';
|
||||
var direct = n.is_direct_peer;
|
||||
var isOurs = (npub && npub === gOurNpub);
|
||||
var badge = '<span class="peer-badge' +
|
||||
(direct ? '' : ' indirect') + '" title="' +
|
||||
(direct ? 'direct peer' : 'not connected') + '"></span>';
|
||||
|
||||
/* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */
|
||||
var endpointsStr = '—';
|
||||
if (n.endpoints && n.endpoints.length) {
|
||||
var ep = n.endpoints[0];
|
||||
endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || '');
|
||||
if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1);
|
||||
}
|
||||
|
||||
/* Connect: clickable text, not a button. */
|
||||
var firstEp = (n.endpoints && n.endpoints[0]) || {};
|
||||
var addr = firstEp.addr || '';
|
||||
var transport = firstEp.transport || 'udp';
|
||||
var connectLink = '';
|
||||
if (isOurs) {
|
||||
connectLink = '<span class="dir-ours-tag">this is you</span>';
|
||||
} else if (addr && !direct) {
|
||||
connectLink = '<a class="dir-connect-link" href="javascript:void(0)" onclick="connectFromDirectory(\'' +
|
||||
esc(npub).replace(/'/g, "\\'") + '\', \'' +
|
||||
esc(addr).replace(/'/g, "\\'") + '\', \'' +
|
||||
esc(transport) + '\')">connect</a>';
|
||||
} else if (direct) {
|
||||
connectLink = '<span class="note">connected</span>';
|
||||
}
|
||||
|
||||
var rowClass = isOurs ? ' class="dir-ours-row"' : '';
|
||||
html += '<tr' + rowClass + '>';
|
||||
html += '<td>' + badge + '</td>';
|
||||
html += '<td class="mono">' + esc(npub) + '</td>';
|
||||
html += '<td class="mono">' + endpointsStr + '</td>';
|
||||
html += '<td>' + connectLink + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function fetchDirectory() {
|
||||
var empty = document.getElementById('dir-empty');
|
||||
empty.style.display = '';
|
||||
empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…';
|
||||
document.getElementById('dir-table').style.display = 'none';
|
||||
document.getElementById('dir-actions').style.display = 'none';
|
||||
return sovereignGet('sovereign://fips/directory?_=' + Date.now())
|
||||
.then(function(data) { renderDirectory(data); })
|
||||
.catch(function(e) {
|
||||
empty.textContent = 'Failed to load directory: ' + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function connectFromDirectory(npub, address, transport) {
|
||||
/* Switch to the Peers tab, pre-fill the add-peer form, and connect. */
|
||||
switchTab('peers');
|
||||
document.getElementById('peer-npub').value = npub;
|
||||
document.getElementById('peer-address').value = address;
|
||||
var sel = document.getElementById('peer-transport');
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === transport) {
|
||||
sel.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
connectPeer();
|
||||
}
|
||||
|
||||
/* ── Refresh + auto-refresh ─────────────────────────────────────── */
|
||||
|
||||
function refresh() {
|
||||
showMsg('');
|
||||
var activeTab = document.querySelector('.tab-btn.active');
|
||||
var tabName = activeTab ? activeTab.dataset.tab : 'status';
|
||||
var promises = [fetchStatus(), fetchPeers()];
|
||||
if (tabName === 'network') promises.push(fetchNetwork());
|
||||
if (tabName === 'tree') promises.push(fetchTree());
|
||||
Promise.all(promises).then(function() {});
|
||||
}
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', refresh);
|
||||
document.getElementById('connect-btn').addEventListener('click', connectPeer);
|
||||
document.getElementById('restart-btn').addEventListener('click', restartService);
|
||||
var dirRefreshBtn = document.getElementById('dir-refresh-btn');
|
||||
if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory);
|
||||
|
||||
/* Initial load. */
|
||||
refresh();
|
||||
|
||||
/* Auto-refresh status + peers every 5s. Network/tree refresh on
|
||||
* tab activation and every 10s when active. */
|
||||
setInterval(function() {
|
||||
fetchStatus();
|
||||
fetchPeers();
|
||||
}, 5000);
|
||||
|
||||
setInterval(function() {
|
||||
var activeTab = document.querySelector('.tab-btn.active');
|
||||
if (!activeTab) return;
|
||||
var tabName = activeTab.dataset.tab;
|
||||
if (tabName === 'network') fetchNetwork();
|
||||
if (tabName === 'tree') fetchTree();
|
||||
}, 10000);
|
||||
369
www/js/perf-probe.js
Normal file
369
www/js/perf-probe.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* perf-probe.js — per-tab performance probe for sovereign_browser
|
||||
*
|
||||
* Injected by WebKitUserContentManager into every non-sovereign:// page
|
||||
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
|
||||
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
|
||||
* - resource entries (PerformanceObserver 'resource') -> net counts
|
||||
* - requestAnimationFrame cadence -> fps
|
||||
* - patched setTimeout/setInterval -> active timer count + top intervals
|
||||
* - patched addEventListener -> event listener count
|
||||
* - patched Worker -> worker count
|
||||
* - performance.memory -> heap used
|
||||
* - document.getElementsByTagName('*').length -> DOM node count
|
||||
*
|
||||
* Batches a report every 1s and POSTs it to:
|
||||
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
|
||||
*
|
||||
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
|
||||
* tag injected before this script runs (see inject_perf_probe() in
|
||||
* nostr_bridge.c / main.c). If absent, the report is dropped.
|
||||
*
|
||||
* The probe is a no-op on sovereign:// pages (the injector skips them)
|
||||
* to avoid self-noise and recursion.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Honor the skip flag set by the preamble on sovereign:// / about:
|
||||
// pages. The skip flag is re-evaluated on every navigation (the
|
||||
// preamble runs at document_start for each new document), so we also
|
||||
// reset our installed flag here to allow re-injection after a
|
||||
// navigation from an internal page to a normal one.
|
||||
if (window.__sbPerfProbeSkip) return;
|
||||
if (window.__sbPerfProbe) return; // already running in this document
|
||||
window.__sbPerfProbe = true;
|
||||
|
||||
// Tab index is injected by the browser via window.__sbPerfTabIndex
|
||||
// (set by the preamble that runs before this script). Read it once.
|
||||
var TAB_INDEX = window.__sbPerfTabIndex;
|
||||
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
|
||||
|
||||
// Capture the native timers early — the heartbeat (below) needs
|
||||
// origSetTimeout before the timer-patching block runs.
|
||||
var origSetTimeout = window.setTimeout;
|
||||
var origSetInterval = window.setInterval;
|
||||
var origClearTimeout = window.clearTimeout;
|
||||
var origClearInterval = window.clearInterval;
|
||||
|
||||
// ── Long tasks ──────────────────────────────────────────────────
|
||||
var longTasks = []; // last 30s of {t, duration, name}
|
||||
var longTasksLastSec = []; // tasks in the current 1s window
|
||||
var longTaskSources = {}; // name -> {total_ms, count}
|
||||
var longTaskSupported = false;
|
||||
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
try {
|
||||
var ltObs = new PerformanceObserver(function (list) {
|
||||
list.getEntries().forEach(function (e) {
|
||||
var rec = {
|
||||
t: performance.now(),
|
||||
duration: e.duration,
|
||||
name: e.name || 'unknown',
|
||||
attribution: (e.attribution && e.attribution[0])
|
||||
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
|
||||
: ''
|
||||
};
|
||||
longTasks.push(rec);
|
||||
longTasksLastSec.push(rec);
|
||||
var key = rec.name || 'unknown';
|
||||
if (!longTaskSources[key]) {
|
||||
longTaskSources[key] = { total_ms: 0, count: 0 };
|
||||
}
|
||||
longTaskSources[key].total_ms += rec.duration;
|
||||
longTaskSources[key].count++;
|
||||
});
|
||||
});
|
||||
ltObs.observe({ entryTypes: ['longtask'] });
|
||||
longTaskSupported = true;
|
||||
} catch (e) { /* longtask not supported */ }
|
||||
}
|
||||
|
||||
// ── Main-thread blockage fallback ───────────────────────────────
|
||||
// WebKitGTK does not fire 'longtask' entries, so we measure main-
|
||||
// thread blockage directly: schedule a 0ms heartbeat and measure
|
||||
// how late it fires. If the main thread is blocked by a busy loop,
|
||||
// the heartbeat is delayed. The total delay in a 1s window is a
|
||||
// proxy for cpu_busy_percent.
|
||||
var heartbeatDelayMs = 0; // accumulated delay in the current window
|
||||
var heartbeatLast = performance.now();
|
||||
function heartbeat() {
|
||||
var now = performance.now();
|
||||
var expected = heartbeatLast + 50; // we schedule every 50ms
|
||||
var delay = now - expected;
|
||||
if (delay > 5) { // ignore small scheduler jitter
|
||||
heartbeatDelayMs += delay;
|
||||
// Record as a synthetic long task if > 50ms.
|
||||
if (delay > 50) {
|
||||
var rec = { t: now, duration: delay, name: 'main-thread-block',
|
||||
attribution: '' };
|
||||
longTasks.push(rec);
|
||||
longTasksLastSec.push(rec);
|
||||
var key = 'main-thread-block';
|
||||
if (!longTaskSources[key]) {
|
||||
longTaskSources[key] = { total_ms: 0, count: 0 };
|
||||
}
|
||||
longTaskSources[key].total_ms += delay;
|
||||
longTaskSources[key].count++;
|
||||
}
|
||||
}
|
||||
heartbeatLast = now;
|
||||
origSetTimeout.call(window, heartbeat, 50);
|
||||
}
|
||||
// Always start the heartbeat. On engines where longtask fires
|
||||
// reliably, the heartbeat delay will be near-zero (long tasks are
|
||||
// captured by the observer). On WebKitGTK, where longtask is
|
||||
// accepted but never fires, the heartbeat is the sole source of
|
||||
// cpu_busy measurement. The report() function takes the max of
|
||||
// both, so there's no double-counting.
|
||||
origSetTimeout.call(window, heartbeat, 50);
|
||||
|
||||
// ── Resource entries (network) ────────────────────────────────
|
||||
var netRequestsLastSec = [];
|
||||
var netEndpoints = {}; // url-prefix -> count
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
try {
|
||||
var resObs = new PerformanceObserver(function (list) {
|
||||
list.getEntries().forEach(function (e) {
|
||||
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
|
||||
var key = e.name.split('?')[0];
|
||||
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
|
||||
});
|
||||
});
|
||||
resObs.observe({ entryTypes: ['resource'] });
|
||||
} catch (e) { /* resource not supported */ }
|
||||
}
|
||||
|
||||
// ── FPS via requestAnimationFrame ───────────────────────────────
|
||||
var rafFrames = 0;
|
||||
var fps = 0;
|
||||
function rafLoop(ts) {
|
||||
rafFrames++;
|
||||
requestAnimationFrame(rafLoop);
|
||||
}
|
||||
requestAnimationFrame(rafLoop);
|
||||
|
||||
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
|
||||
var activeTimers = {}; // id -> {code, interval_ms, count}
|
||||
var timerTop = {}; // "code|interval" -> count
|
||||
var nextTimerId = 1;
|
||||
|
||||
function labelFor(fn) {
|
||||
try {
|
||||
var s = (fn && fn.name) ? fn.name : 'anonymous';
|
||||
// Truncate long inline-function bodies.
|
||||
if (s.length > 40) s = s.slice(0, 40);
|
||||
return s;
|
||||
} catch (e) { return 'anonymous'; }
|
||||
}
|
||||
|
||||
// origSetTimeout / origSetInterval / origClearTimeout /
|
||||
// origClearInterval were captured at the top of the IIFE (before the
|
||||
// heartbeat was installed), so they reference the native timers.
|
||||
|
||||
window.setTimeout = function (fn, delay) {
|
||||
var id = nextTimerId++;
|
||||
var label = labelFor(fn);
|
||||
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
|
||||
var key = label + '|0';
|
||||
timerTop[key] = (timerTop[key] || 0) + 1;
|
||||
var args = Array.prototype.slice.call(arguments, 2);
|
||||
var wrapped = function () {
|
||||
delete activeTimers[id];
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
return origSetTimeout.call(this, wrapped, delay);
|
||||
};
|
||||
|
||||
window.setInterval = function (fn, delay) {
|
||||
var id = nextTimerId++;
|
||||
var label = labelFor(fn);
|
||||
var interval = delay | 0;
|
||||
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
|
||||
var key = label + '|' + interval;
|
||||
timerTop[key] = (timerTop[key] || 0) + 1;
|
||||
var args = Array.prototype.slice.call(arguments, 2);
|
||||
var wrapped = function () {
|
||||
if (activeTimers[id]) activeTimers[id].count++;
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
return origSetInterval.call(this, wrapped, delay);
|
||||
};
|
||||
|
||||
window.clearTimeout = function (id) {
|
||||
delete activeTimers[id];
|
||||
return origClearTimeout.call(this, id);
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
delete activeTimers[id];
|
||||
return origClearInterval.call(this, id);
|
||||
};
|
||||
|
||||
// ── Event listener count (patched addEventListener) ─────────────
|
||||
var eventListenerCount = 0;
|
||||
var origAEL = EventTarget.prototype.addEventListener;
|
||||
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
||||
// Skip our own internal listeners (tagged with __sbInternal).
|
||||
if (!fn || !fn.__sbInternal) eventListenerCount++;
|
||||
return origAEL.call(this, type, fn, opts);
|
||||
};
|
||||
|
||||
// ── Worker count (patched Worker constructor) ───────────────────
|
||||
var workerCount = 0;
|
||||
var OrigWorker = window.Worker;
|
||||
if (OrigWorker) {
|
||||
window.Worker = function (url, opts) {
|
||||
workerCount++;
|
||||
return new OrigWorker(url, opts);
|
||||
};
|
||||
window.Worker.prototype = OrigWorker.prototype;
|
||||
}
|
||||
|
||||
// ── In-flight network (patched fetch + XHR.open) ────────────────
|
||||
var netInFlight = 0;
|
||||
var origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = function () {
|
||||
netInFlight++;
|
||||
var p = origFetch.apply(this, arguments);
|
||||
var dec = function () { if (netInFlight > 0) netInFlight--; };
|
||||
p.then(dec, dec);
|
||||
return p;
|
||||
};
|
||||
}
|
||||
var origXhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
netInFlight++;
|
||||
var onEnd = function () {
|
||||
if (netInFlight > 0) netInFlight--;
|
||||
};
|
||||
onEnd.__sbInternal = true;
|
||||
this.addEventListener('loadend', onEnd);
|
||||
return origXhrOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── Reporting ───────────────────────────────────────────────────
|
||||
function report() {
|
||||
// Trim long-task timeline to last 30s.
|
||||
var now = performance.now();
|
||||
while (longTasks.length && (now - longTasks[0].t) > 30000) {
|
||||
longTasks.shift();
|
||||
}
|
||||
|
||||
// cpu_busy_percent: sum of long-task durations in the last 1s
|
||||
// window. If the longtask PerformanceObserver isn't supported
|
||||
// (WebKitGTK), fall back to the heartbeat-delay measurement.
|
||||
var busyMs = 0;
|
||||
for (var i = 0; i < longTasksLastSec.length; i++) {
|
||||
busyMs += longTasksLastSec[i].duration;
|
||||
}
|
||||
// If the heartbeat measured more blockage than the longtask
|
||||
// observer (e.g. on WebKitGTK where longtask never fires), use
|
||||
// the heartbeat measurement.
|
||||
if (heartbeatDelayMs > busyMs) {
|
||||
busyMs = heartbeatDelayMs;
|
||||
}
|
||||
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
|
||||
if (cpuBusy > 100) cpuBusy = 100;
|
||||
heartbeatDelayMs = 0; // reset for next window
|
||||
|
||||
// Top timer sources.
|
||||
var timerTopArr = [];
|
||||
Object.keys(timerTop).forEach(function (k) {
|
||||
var parts = k.split('|');
|
||||
timerTopArr.push({
|
||||
code: parts[0],
|
||||
interval_ms: parseInt(parts[1], 10) || 0,
|
||||
count: timerTop[k]
|
||||
});
|
||||
});
|
||||
timerTopArr.sort(function (a, b) { return b.count - a.count; });
|
||||
timerTopArr = timerTopArr.slice(0, 5);
|
||||
|
||||
// Top network endpoints (last window).
|
||||
var netTopArr = [];
|
||||
Object.keys(netEndpoints).forEach(function (u) {
|
||||
netTopArr.push({ url: u, count: netEndpoints[u] });
|
||||
});
|
||||
netTopArr.sort(function (a, b) { return b.count - a.count; });
|
||||
netTopArr = netTopArr.slice(0, 5);
|
||||
|
||||
// Top long-task sources.
|
||||
var ltSourcesArr = [];
|
||||
Object.keys(longTaskSources).forEach(function (k) {
|
||||
ltSourcesArr.push({
|
||||
name: k,
|
||||
total_ms: Math.round(longTaskSources[k].total_ms),
|
||||
count: longTaskSources[k].count
|
||||
});
|
||||
});
|
||||
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
|
||||
ltSourcesArr = ltSourcesArr.slice(0, 8);
|
||||
|
||||
// Heap.
|
||||
var heapMb = -1;
|
||||
if (performance.memory && performance.memory.usedJSHeapSize) {
|
||||
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
|
||||
}
|
||||
|
||||
// DOM node count.
|
||||
var domNodes = -1;
|
||||
try {
|
||||
domNodes = document.getElementsByTagName('*').length;
|
||||
} catch (e) { /* document not ready */ }
|
||||
|
||||
var payload = {
|
||||
tab_index: TAB_INDEX,
|
||||
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
|
||||
fps: fps,
|
||||
timer_count: Object.keys(activeTimers).length,
|
||||
timer_top: timerTopArr,
|
||||
net_in_flight: netInFlight,
|
||||
net_requests_last_sec: netRequestsLastSec.length,
|
||||
net_top_endpoints: netTopArr,
|
||||
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
|
||||
event_listener_count: eventListenerCount,
|
||||
worker_count: workerCount,
|
||||
dom_node_count: domNodes,
|
||||
long_tasks_last_sec: longTasksLastSec.map(function (r) {
|
||||
return {
|
||||
duration: Math.round(r.duration),
|
||||
name: r.name,
|
||||
attribution: r.attribution
|
||||
};
|
||||
}),
|
||||
long_task_top_sources: ltSourcesArr,
|
||||
long_task_timeline: longTasks.map(function (r) {
|
||||
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
|
||||
})
|
||||
};
|
||||
|
||||
// Reset per-window accumulators.
|
||||
longTasksLastSec = [];
|
||||
netRequestsLastSec = [];
|
||||
netEndpoints = {};
|
||||
timerTop = {};
|
||||
|
||||
// Send. Use sync XHR to sovereign:// (same convention as the
|
||||
// window.nostr shim — WebKit's custom scheme handler doesn't
|
||||
// reliably support fetch() or async XHR for sovereign:// URLs).
|
||||
try {
|
||||
var body = encodeURIComponent(JSON.stringify(payload));
|
||||
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
|
||||
'&body=' + body;
|
||||
var x = new XMLHttpRequest();
|
||||
x.open('GET', url, false); // synchronous
|
||||
x.send();
|
||||
} catch (e) { /* swallow — probe must never break the page */ }
|
||||
}
|
||||
|
||||
// FPS counter: count frames in each 1s window.
|
||||
setInterval(function () {
|
||||
fps = rafFrames;
|
||||
rafFrames = 0;
|
||||
}, 1000);
|
||||
|
||||
// Report every 1s. Use the original setInterval so we don't track
|
||||
// ourselves in activeTimers.
|
||||
origSetInterval.call(window, report, 1000);
|
||||
})();
|
||||
144
www/processes.css
Normal file
144
www/processes.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Processes page — sovereign://processes
|
||||
* Imports the shared sovereign:// base theme, then adds processes-specific
|
||||
* layout for the process/tab tables, heat bars, and drill-down panel. */
|
||||
@import url("sovereign://sovereign-base.css");
|
||||
|
||||
.note { font-size: 12px; font-weight: normal; }
|
||||
code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; }
|
||||
.url-sub { font-size: 11px; }
|
||||
.dim { color: var(--muted); }
|
||||
|
||||
/* Tab navigation (shared with fips.css pattern). */
|
||||
.tabs {
|
||||
display: flex; gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: var(--bg); color: var(--muted);
|
||||
border: 1px solid transparent; border-bottom: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 6px 16px; cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; font-weight: bold;
|
||||
margin-left: 0; min-width: auto;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--primary); }
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-color: var(--border);
|
||||
border-bottom: 1px solid var(--bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
#refresh-btn { margin-left: 12px; }
|
||||
|
||||
/* Process + tab tables. */
|
||||
#proc-table, #tabs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
#proc-table th, #proc-table td,
|
||||
#tabs-table th, #tabs-table td {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#proc-table th, #tabs-table th {
|
||||
color: var(--muted);
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#proc-table th:hover, #tabs-table th:hover { color: var(--primary); }
|
||||
#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
|
||||
#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
|
||||
#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
|
||||
#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
|
||||
|
||||
#proc-table td.title-cell,
|
||||
#tabs-table td.title-cell {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#proc-table td.url-cell,
|
||||
#tabs-table td.url-cell {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* CPU values: plain text, no shaded background. Red only for >= 90%,
|
||||
* otherwise the default foreground color. */
|
||||
.heat {
|
||||
font-weight: bold;
|
||||
color: var(--fg);
|
||||
}
|
||||
.heat.hot { color: var(--accent); }
|
||||
|
||||
/* Renderer row: clickable to expand hosted tabs. */
|
||||
.renderer-row { cursor: pointer; }
|
||||
.renderer-row:hover { background: var(--border); }
|
||||
.hosted-tabs-row td { padding-left: 24px; color: var(--muted); }
|
||||
.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; }
|
||||
.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; }
|
||||
|
||||
/* Drill-down panel. */
|
||||
#drilldown {
|
||||
margin-top: 24px;
|
||||
border-top: 2px solid var(--primary);
|
||||
padding-top: 12px;
|
||||
}
|
||||
#drilldown h2 { margin-bottom: 8px; }
|
||||
#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; }
|
||||
|
||||
.sub-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
.sub-table th, .sub-table td {
|
||||
text-align: left;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sub-table th { color: var(--muted); font-weight: bold; }
|
||||
.sub-table td.mono { font-family: var(--font); word-break: break-all; }
|
||||
|
||||
/* Long-task timeline: a horizontal bar of vertical lines, one per
|
||||
* long task, height scaled by duration. */
|
||||
.timeline {
|
||||
height: 80px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
}
|
||||
.timeline .tl-bar {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
margin-right: 1px;
|
||||
background: var(--accent);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.timeline .tl-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.actions { margin-top: 16px; }
|
||||
.actions .btn { margin-right: 8px; }
|
||||
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
.mono { font-family: var(--font); word-break: break-all; }
|
||||
110
www/processes.html
Normal file
110
www/processes.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Processes — Sovereign Browser</title>
|
||||
<link rel="stylesheet" href="sovereign://processes.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Processes <button class="btn" id="refresh-btn">Refresh</button></h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="processes">Processes</button>
|
||||
<button class="tab-btn" data-tab="tabs">Tabs</button>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status"></div>
|
||||
|
||||
<!-- Processes tab -->
|
||||
<div class="tab-panel active" id="tab-processes">
|
||||
<p class="note">
|
||||
All sovereign_browser processes: the main browser, WebKit renderer
|
||||
/ network / GPU processes, and managed Tor/FIPS subprocesses.
|
||||
CPU% is computed from <code>/proc</code> utime+stime deltas
|
||||
(first sample is always 0). Renderer rows expand to show their
|
||||
hosted tabs.
|
||||
</p>
|
||||
<table id="proc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="pid">PID</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="cpu_percent">CPU%</th>
|
||||
<th data-sort="rss_kb">RSS</th>
|
||||
<th data-sort="pss_kb">PSS</th>
|
||||
<th data-sort="threads">Threads</th>
|
||||
<th data-sort="uptime_sec">Uptime</th>
|
||||
<th data-sort="state">State</th>
|
||||
<th data-sort="ownership">Type</th>
|
||||
<th>Tabs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="proc-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Tabs tab -->
|
||||
<div class="tab-panel" id="tab-tabs">
|
||||
<p class="note">
|
||||
Per-tab performance from the in-page probe. <b>CPU-busy%</b> is
|
||||
the fraction of the last second spent in long (>50ms)
|
||||
main-thread tasks — the direct measure of which tab is hogging
|
||||
CPU. Click a row for a drill-down (long-task sources, timers,
|
||||
network endpoints, heap, workers).
|
||||
</p>
|
||||
<table id="tabs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="index">#</th>
|
||||
<th data-sort="title">Title</th>
|
||||
<th data-sort="cpu_busy_percent">CPU-busy%</th>
|
||||
<th data-sort="fps">FPS</th>
|
||||
<th data-sort="timer_count">Timers</th>
|
||||
<th data-sort="net_in_flight">Net</th>
|
||||
<th data-sort="heap_used_mb">Heap</th>
|
||||
<th data-sort="dom_node_count">DOM</th>
|
||||
<th data-sort="webprocess_pid">WPID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tabs-body"></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Drill-down panel -->
|
||||
<div id="drilldown" style="display:none">
|
||||
<h2 id="dd-title">Tab detail</h2>
|
||||
<div class="card" id="dd-summary"></div>
|
||||
|
||||
<h3>Long-task sources <span class="note">(top scripts by total ms)</span></h3>
|
||||
<table class="sub-table" id="dd-lt-sources">
|
||||
<thead><tr><th>name</th><th>total ms</th><th>count</th></tr></thead>
|
||||
<tbody id="dd-lt-sources-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h3>Active timers <span class="note">(top by frequency)</span></h3>
|
||||
<table class="sub-table" id="dd-timers">
|
||||
<thead><tr><th>code</th><th>interval ms</th><th>count</th></tr></thead>
|
||||
<tbody id="dd-timers-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h3>Network endpoints <span class="note">(top by request count)</span></h3>
|
||||
<table class="sub-table" id="dd-net">
|
||||
<thead><tr><th>url</th><th>count</th></tr></thead>
|
||||
<tbody id="dd-net-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h3>Long-task timeline <span class="note">(last 30s)</span></h3>
|
||||
<div id="dd-timeline" class="timeline"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" id="dd-reload">Reload tab</button>
|
||||
<button class="btn" id="dd-suspend">Suspend tab</button>
|
||||
<button class="btn" id="dd-close">Close tab</button>
|
||||
<button class="btn" id="dd-close-panel">Close panel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="sovereign://processes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
451
www/processes.js
Normal file
451
www/processes.js
Normal 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);
|
||||
@@ -118,6 +118,13 @@
|
||||
<button class="save-btn" onclick="save('search_engine')">Save</button></div>
|
||||
</div>
|
||||
|
||||
<h2>Network</h2>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">FIPS Mesh Network</div>
|
||||
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
|
||||
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
|
||||
</div>
|
||||
|
||||
<h2>Security</h2>
|
||||
|
||||
<div class="setting">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user