Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7b5eb72c | ||
|
|
2bc5ec7771 | ||
|
|
992b09357a | ||
|
|
24d07946b9 | ||
|
|
e0607fe045 | ||
|
|
ae43decd4d | ||
|
|
5e035bfe6b | ||
|
|
473e2e4a96 | ||
|
|
9a55fe15f7 |
@@ -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
|
||||
|
||||
|
||||
@@ -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/process_info.c src/perf_probe.c
|
||||
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c src/site_downloader.c
|
||||
|
||||
# Web files embedded into the binary as C byte arrays.
|
||||
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
+144
-27
@@ -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
|
||||
|
||||
+31
-5
@@ -40,7 +40,9 @@ cleanup_on_exit() {
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
# Guard so an empty argument (e.g. a failed tarball path) does not cause
|
||||
# this function to return non-zero and abort the script under `set -e`.
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
@@ -280,7 +282,19 @@ build_release_binary() {
|
||||
create_source_tarball() {
|
||||
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
if tar -czf "$tarball_name" \
|
||||
# Write the tarball to a temp path OUTSIDE the archived tree (.) so that
|
||||
# creating it does not change the directory while tar is reading it
|
||||
# (which makes tar emit "file changed as we read it" and exit 1, which
|
||||
# under `set -e` would abort the whole release flow). Move it into place
|
||||
# only after tar finishes.
|
||||
local tmp_tarball
|
||||
tmp_tarball=$(mktemp -t "${tarball_name}.XXXXXX") || return 1
|
||||
|
||||
# tar exits 1 for "file changed as we read it", which is benign here
|
||||
# because the only thing changing is the tarball we are writing (now in
|
||||
# /tmp). Accept exit codes 0 and 1 as success; anything else is fatal.
|
||||
set +e
|
||||
tar -czf "$tmp_tarball" \
|
||||
--exclude="$BIN_NAME" \
|
||||
--exclude='.git*' \
|
||||
--exclude='*.log' \
|
||||
@@ -290,11 +304,23 @@ create_source_tarball() {
|
||||
--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
|
||||
. > /dev/null 2>&1
|
||||
local tar_rc=$?
|
||||
set -e
|
||||
|
||||
if [[ $tar_rc -ne 0 && $tar_rc -ne 1 ]]; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "tar failed with exit code $tar_rc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! mv "$tmp_tarball" "$tarball_name" 2>/dev/null; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "Failed to move tarball into place: $tarball_name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$tarball_name"
|
||||
}
|
||||
|
||||
create_gitea_release() {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Plan: Download Website for Offline Viewing
|
||||
|
||||
## Goal
|
||||
|
||||
Add a "Download Website" option to the tab right-click context menu. It crawls
|
||||
the current site (same-origin, bounded by a page cap), saves all pages and
|
||||
assets to a per-profile directory, rewrites links to relative paths, and opens
|
||||
the offline copy in a new tab via `file://`.
|
||||
|
||||
## User decisions
|
||||
|
||||
- **Depth:** Entire site — follow all same-origin links, no depth limit, bounded
|
||||
by a max-page-count safety cap (default 500).
|
||||
- **Storage:** Per-profile:
|
||||
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/<hostname>/`
|
||||
(falls back to `~/.sovereign_browser/offline_sites/<hostname>/` when not
|
||||
logged in).
|
||||
- **Feedback:** Small modal progress dialog with pages/files counts, a progress
|
||||
bar, and a Cancel button.
|
||||
|
||||
## Trigger
|
||||
|
||||
Right-click on a tab → "Download Website" menu item. Added to **both** tab
|
||||
context menus in [`src/tab_manager.c`](../src/tab_manager.c):
|
||||
|
||||
- `on_tab_label_button_press` menu (line ~828)
|
||||
- `on_notebook_button_press` menu (line ~751)
|
||||
|
||||
The callback reads the right-clicked tab's `current_url` and is only active for
|
||||
`http://` / `https://` URLs (greyed out / error dialog otherwise).
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Right-click tab] --> B[Download Website menu item]
|
||||
B --> C{URL is http/https?}
|
||||
C -- No --> D[Show error, abort]
|
||||
C -- Yes --> E[Show progress dialog]
|
||||
E --> F[Start worker thread BFS crawl]
|
||||
F --> G[Fetch page via libsoup]
|
||||
G --> H[Save HTML to offline_sites/host/path]
|
||||
H --> I[Extract same-origin links + assets]
|
||||
I --> J{Cancel clicked?}
|
||||
J -- Yes --> K[Stop crawl]
|
||||
J -- No --> L{More URLs and under cap?}
|
||||
L -- Yes --> G
|
||||
L -- No --> M[Close dialog]
|
||||
K --> M
|
||||
M --> N[Open file://offline_sites/host/index.html in new tab]
|
||||
```
|
||||
|
||||
### New files
|
||||
|
||||
#### `src/site_downloader.h`
|
||||
|
||||
Public API (self-contained, called from the tab context menu):
|
||||
|
||||
```c
|
||||
/* Start downloading the website at start_url. Shows a modal progress
|
||||
* dialog parented to `parent`. On completion (or cancel) opens the
|
||||
* offline copy in a new tab. No-op for non-http(s) URLs. */
|
||||
void site_downloader_start(const char *start_url, GtkWindow *parent);
|
||||
```
|
||||
|
||||
#### `src/site_downloader.c`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. **Progress dialog** — `GtkDialog` with:
|
||||
- `GtkLabel` showing hostname + "Pages: N / Files: M"
|
||||
- `GtkProgressBar` (pulse mode; or fraction = pages / cap)
|
||||
- Cancel `GtkButton` → sets `g_atomic_int_set(&cancel, 1)`
|
||||
|
||||
2. **Worker thread** — `g_thread_new("site-dl", ...)` running a BFS crawl:
|
||||
- `SoupSession` (libsoup-3.0; pattern from
|
||||
[`src/agent_llm.c`](../src/agent_llm.c:285) and
|
||||
[`src/search.c`](../src/search.c:158)).
|
||||
- Queue (GQueue) of URLs to fetch + a `GHashTable` visited-set.
|
||||
- Politeness delay between requests (default 100ms).
|
||||
|
||||
3. **Fetch + save** — for each URL:
|
||||
- `soup_session_send_and_read()` (sync; we're on a worker thread).
|
||||
- Map URL → local path: mirror the URL path under the site root;
|
||||
directory-style URLs → `index.html`; sanitize illegal filename chars.
|
||||
- Write bytes to disk (`g_file_set_contents` for text, manual write for
|
||||
binary).
|
||||
- For HTML responses: extract links, rewrite them to relative paths, then
|
||||
save the rewritten HTML.
|
||||
|
||||
4. **Link extraction & rewriting** (no HTML parser dep in the project, so use
|
||||
regex over `href=`, `src=`, `srcset=`, `poster=`, and CSS `url(...)`):
|
||||
- Resolve each link against the page URL (`g_uri_resolve_relative`).
|
||||
- **Same-origin filter:** keep only links whose host matches the start
|
||||
URL's host (via `g_uri_parse`, pattern from
|
||||
[`src/tor_scheme.c`](../src/tor_scheme.c:74)). Enqueue same-origin HTML
|
||||
pages; download all assets (CSS/JS/images/fonts/etc.) regardless of
|
||||
origin but don't crawl cross-origin pages.
|
||||
- Rewrite kept links to relative file paths so the offline copy works from
|
||||
`file://`.
|
||||
|
||||
5. **Completion** — `g_idle_add` a callback on the main thread that:
|
||||
- Destroys the progress dialog.
|
||||
- Opens `file://<offline_dir>/index.html` in a new tab via
|
||||
`tab_manager_new_tab()`.
|
||||
|
||||
### Modified files
|
||||
|
||||
- [`src/tab_manager.c`](../src/tab_manager.c) — add "Download Website" item to
|
||||
both context menus; new callback `on_tab_download_website` calling
|
||||
`site_downloader_start(tab->current_url, g_window)`.
|
||||
- [`Makefile`](../Makefile) — add `src/site_downloader.c` to `SRC`.
|
||||
- [`src/settings.h`](../src/settings.h) / [`src/settings.c`](../src/settings.c)
|
||||
— add to `browser_settings_t`:
|
||||
- `offline_site_max_pages` (default 500)
|
||||
- `offline_site_request_delay_ms` (default 100)
|
||||
- load/save in `settings_load()` / `settings_save()`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Non-http(s) URLs → menu item shows an error dialog, no crawl.
|
||||
- Not logged in → fall back to global `~/.sovereign_browser/offline_sites/`.
|
||||
- Redirects → follow libsoup redirects; use the final URL for same-origin
|
||||
checks and path mapping.
|
||||
- Binary assets → write raw bytes, never parse/rewrite.
|
||||
- Duplicate URLs → visited-set prevents re-fetching.
|
||||
- Very large sites → max-page cap stops the crawl; dialog shows "cap reached".
|
||||
- Filename sanitization → replace illegal chars (`/`, `?`, `:`, etc.) with `_`.
|
||||
- Cancel → worker checks the cancel flag between requests; partial downloads
|
||||
are kept and the new tab opens to whatever was fetched.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Add settings fields + load/save.
|
||||
2. Create `site_downloader.h` / `site_downloader.c` skeleton: progress dialog
|
||||
+ thread stub + completion callback.
|
||||
3. Implement libsoup fetch + file write + URL→path mapping.
|
||||
4. Implement HTML link extraction, same-origin filter, link rewriting.
|
||||
5. Implement BFS crawl loop: visited-set, cap, cancel flag, progress updates.
|
||||
6. Implement completion callback: open new tab with `file://` URL.
|
||||
7. Wire "Download Website" into both tab context menus in `tab_manager.c`.
|
||||
8. Add `site_downloader.c` to `Makefile`.
|
||||
9. Build (`make`) and test with `tests/local-site/` served via
|
||||
`python3 -m http.server`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Serve `tests/local-site/` with `python3 -m http.server 8000` from the
|
||||
`tests/` directory.
|
||||
- Open `http://localhost:8000/local-site/index.html` in the browser.
|
||||
- Right-click the tab → Download Website.
|
||||
- Verify the progress dialog appears, files are saved under
|
||||
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/localhost/`, and a new
|
||||
tab opens the offline `index.html` with working relative links and assets.
|
||||
- Test Cancel mid-download.
|
||||
- Test a non-http URL (e.g. `sovereign://profile`) → error dialog.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Plan: Keyboard Shortcut to Toggle All Menu Bars (per-window)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a configurable keyboard shortcut that shows/hides all the browser's
|
||||
"menu bars" — the per-tab toolbars, bookmark bars, and the tab strip —
|
||||
**independently per window**. Pressing the shortcut in one window hides
|
||||
that window's chrome only; other windows are unaffected. This mirrors how
|
||||
the agent sidebar already works (per-window `sidebar_visible` on
|
||||
[`window_state_t`](src/tab_manager.c:87)).
|
||||
|
||||
In sovereign_browser there is no traditional `GtkMenuBar`; each tab's chrome
|
||||
is two widgets packed at the top of the tab page
|
||||
([`tab->page`](src/tab_manager.c:2966)):
|
||||
|
||||
1. **`#main-toolbar`** — the horizontal GtkBox holding the hamburger menu
|
||||
button, refresh/stop, back, forward, URL entry, and bookmark button
|
||||
(built in [`tab_create()`](src/tab_manager.c:2973)).
|
||||
2. **`#bookmark-bar`** — the bookmarks toolbar below the URL toolbar
|
||||
([`tab->bookmark_bar`](src/tab_manager.c:3089)).
|
||||
|
||||
Plus the tab strip itself, which is the `GtkNotebook` header, controlled
|
||||
via `gtk_notebook_set_show_tabs()`.
|
||||
|
||||
Hiding all three gives the webview the entire window (a focus / kiosk
|
||||
mode). Pressing the shortcut again restores them.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New shortcut action
|
||||
|
||||
Add `SHORTCUT_TOGGLE_TOOLBARS` to the enum in [`shortcuts.h`](src/shortcuts.h:29)
|
||||
(before `SHORTCUT_COUNT`) and a matching entry in the `g_registry` table in
|
||||
[`shortcuts.c`](src/shortcuts.c:24):
|
||||
|
||||
| Field | Value |
|
||||
|---------|----------------------------------------------------|
|
||||
| id | `toggle_toolbars` |
|
||||
| label | `Toggle menu bars` |
|
||||
| desc | `Show or hide the tab strip, toolbars, and bookmark bars in the active window` |
|
||||
| dflt | `<Control><Shift>m` |
|
||||
|
||||
Default `<Control><Shift>m` ("m" for menu) is free and mnemonic. It is a
|
||||
browser-level shortcut (not consumed by WebKit for web content), so it
|
||||
reaches the window-level `on_key_press` handler reliably.
|
||||
|
||||
Because the settings page enumerates actions via
|
||||
[`sovereign://settings/config`](src/nostr_bridge.c:2994) (which loops over
|
||||
`SHORTCUT_COUNT` calling [`shortcuts_meta()`](src/shortcuts.c:232) /
|
||||
[`shortcuts_get()`](src/shortcuts.c:202)), the new action appears
|
||||
automatically in the Keyboard Shortcuts section of
|
||||
[`www/settings.html`](www/settings.html:74) with no JS changes. NIP-78 sync
|
||||
also picks it up for free via [`shortcuts_serialize()`](src/shortcuts.c:247).
|
||||
|
||||
### 2. Track toolbar widgets on each tab
|
||||
|
||||
The `#main-toolbar` GtkBox is currently a local variable in
|
||||
[`tab_create()`](src/tab_manager.c:2973) and is not stored on
|
||||
[`tab_info_t`](src/tab_manager.h:23). Add a field:
|
||||
|
||||
```c
|
||||
GtkWidget *toolbar; /* #main-toolbar (hamburger + nav + URL) */
|
||||
```
|
||||
|
||||
to `tab_info_t` in [`tab_manager.h`](src/tab_manager.h:23), and assign
|
||||
`tab->toolbar = toolbar;` in [`tab_create()`](src/tab_manager.c:2973).
|
||||
`tab->bookmark_bar` already exists.
|
||||
|
||||
### 3. Per-window visibility flag
|
||||
|
||||
Add a field to [`window_state_t`](src/tab_manager.c:87) in
|
||||
[`tab_manager.c`](src/tab_manager.c):
|
||||
|
||||
```c
|
||||
gboolean toolbars_visible; /* per-window menu-bar visibility */
|
||||
```
|
||||
|
||||
Initialize it to `TRUE` for `g_main_window` (in
|
||||
[`tab_manager_init`](src/tab_manager.c:3598), near where
|
||||
`sidebar_visible = FALSE` is set at line 3643) and for each aux window
|
||||
created in [`tab_manager_new_window`](src/tab_manager.c:1262) (near line
|
||||
1376 where `sidebar_visible = FALSE` is set).
|
||||
|
||||
### 4. Toggle function (active window only)
|
||||
|
||||
Add a public function in [`tab_manager.c`](src/tab_manager.c):
|
||||
|
||||
```c
|
||||
void tab_manager_toggle_toolbars(void);
|
||||
```
|
||||
|
||||
Implementation — mirrors [`tab_manager_toggle_sidebar()`](src/tab_manager.c:4143):
|
||||
|
||||
1. `window_state_t *ws = get_active_window_state();` — resolves the focused
|
||||
window (main or aux), exactly like the sidebar toggle does.
|
||||
2. Flip `ws->toolbars_visible`.
|
||||
3. `gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), ws->toolbars_visible);`
|
||||
— hides/shows the tab strip for this window's notebook only.
|
||||
4. Iterate `g_tabs[0..g_tab_count)` and, for each tab whose **page belongs
|
||||
to this window's notebook** (use [`tab_find_notebook(tab->page)`](src/tab_manager.c:2660)
|
||||
and compare to `ws->notebook`), call
|
||||
`gtk_widget_set_visible(tab->toolbar, ws->toolbars_visible)` and
|
||||
`gtk_widget_set_visible(tab->bookmark_bar, ws->toolbars_visible)`.
|
||||
|
||||
This affects only the active window's tabs and tab strip; other windows
|
||||
keep their own `toolbars_visible` state.
|
||||
|
||||
### 5. Respect hidden state for new tabs
|
||||
|
||||
In [`tab_create()`](src/tab_manager.c:2973), after building `toolbar` and
|
||||
`tab->bookmark_bar`, look up the notebook the tab is being added to (the
|
||||
target notebook — `g_active_notebook` / `get_effective_notebook()` at tab
|
||||
creation time, or `tab_find_notebook(tab->page)` after packing) and its
|
||||
`window_state_t` via [`window_state_for_notebook()`](src/tab_manager.c:4051),
|
||||
then apply that window's flag:
|
||||
|
||||
```c
|
||||
window_state_t *ws = window_state_for_notebook(<target notebook>);
|
||||
gboolean vis = ws ? ws->toolbars_visible : TRUE;
|
||||
gtk_widget_set_visible(toolbar, vis);
|
||||
gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
```
|
||||
|
||||
So a tab opened in a hidden-chrome window is also hidden, while a tab
|
||||
opened in a visible-chrome window stays visible. The tab strip visibility
|
||||
is per-notebook via `gtk_notebook_set_show_tabs()`, so no extra work is
|
||||
needed there.
|
||||
|
||||
### 6. Dispatch the shortcut
|
||||
|
||||
Add a case to the switch in [`on_key_press`](src/main.c:611) in
|
||||
[`main.c`](src/main.c):
|
||||
|
||||
```c
|
||||
case SHORTCUT_TOGGLE_TOOLBARS:
|
||||
tab_manager_toggle_toolbars();
|
||||
return TRUE;
|
||||
```
|
||||
|
||||
The key-press handler is window-level, so `get_active_window_state()`
|
||||
inside the toggle resolves to the window that had focus when the key was
|
||||
pressed — giving the per-window behavior.
|
||||
|
||||
### 7. Expose in tab_manager.h
|
||||
|
||||
Add the prototype for `tab_manager_toggle_toolbars(void)` near the other
|
||||
toggle prototypes ([`tab_manager_toggle_inspector`](src/tab_manager.h),
|
||||
[`tab_manager_toggle_sidebar`](src/tab_manager.h)).
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---------------------|---------------------------------------------------------------|
|
||||
| [`src/shortcuts.h`](src/shortcuts.h:29) | Add `SHORTCUT_TOGGLE_TOOLBARS` to enum |
|
||||
| [`src/shortcuts.c`](src/shortcuts.c:24) | Add registry entry (id/label/desc/dflt) |
|
||||
| [`src/tab_manager.h`](src/tab_manager.h:23) | Add `GtkWidget *toolbar` field + `tab_manager_toggle_toolbars` prototype |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:87) | Add `toolbars_visible` to `window_state_t`; init to TRUE for main + aux windows |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:2973) | Store `tab->toolbar`; apply window's `toolbars_visible` on creation; implement `tab_manager_toggle_toolbars()` (active window only: tab strip + that window's tabs' toolbars/bookmark bars) |
|
||||
| [`src/main.c`](src/main.c:611) | Add `case SHORTCUT_TOGGLE_TOOLBARS` dispatch |
|
||||
|
||||
No changes needed in `www/` (settings page auto-discovers the action) or in
|
||||
`settings_sync.c` (NIP-78 sync already serializes all actions).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make` — builds clean.
|
||||
2. `./browser.sh restart --login-method generate --url https://example.com`
|
||||
3. Press `Ctrl+Shift+M` → in the focused window, toolbars + bookmark bar +
|
||||
tab strip disappear; the webview fills the entire window. Press again →
|
||||
they reappear.
|
||||
4. Open a second window (`Ctrl+N`). Hide chrome in window A with
|
||||
`Ctrl+Shift+M`; window B's chrome stays visible. Hide B independently.
|
||||
5. With chrome hidden in a window, `Ctrl+T` opens a new tab in that window
|
||||
whose toolbars are also hidden; the tab strip stays hidden too.
|
||||
6. Open `sovereign://settings` → Keyboard Shortcuts section lists
|
||||
"Toggle menu bars" with `Ctrl+Shift+M`; rebind it and confirm the new
|
||||
binding works.
|
||||
@@ -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.
|
||||
+47
-1
@@ -28,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;
|
||||
|
||||
@@ -379,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);
|
||||
@@ -389,9 +401,30 @@ 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);
|
||||
@@ -483,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");
|
||||
@@ -490,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. */
|
||||
|
||||
+2
-2
@@ -230,8 +230,8 @@ const mcp_tool_def_t tool_defs[] = {
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"click_at",
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation. Optional 'button': 1=left (default), 2=middle, 3=right (opens the native context menu).",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"button\":{\"type\":\"integer\",\"enum\":[1,2,3],\"default\":1}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"fill",
|
||||
"Clear an input and fill it with a value.",
|
||||
|
||||
+59
-1
@@ -14,10 +14,50 @@
|
||||
#include <libsoup/soup.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* Forward declarations from agent_tools.c */
|
||||
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
|
||||
|
||||
/* ── Discovery file ───────────────────────────────────────────────── *
|
||||
* When the configured port (default 17777) is already in use by another
|
||||
* browser instance, we fall back to an OS-assigned port (bind to 0). So
|
||||
* that external clients and browser.sh can find the actual port, we write
|
||||
* a per-instance discovery file at /tmp/sovereign_browser_<pid>.port
|
||||
* containing the PID and port. browser.sh globs these files to enumerate
|
||||
* running instances. The file is removed on clean shutdown. */
|
||||
static char g_discovery_path[128] = {0};
|
||||
|
||||
static void build_discovery_path(char *out, size_t out_sz) {
|
||||
snprintf(out, out_sz, "/tmp/sovereign_browser_%d.port", (int)getpid());
|
||||
}
|
||||
|
||||
static void write_discovery_file(int port) {
|
||||
char path[128];
|
||||
FILE *fp;
|
||||
build_discovery_path(path, sizeof(path));
|
||||
fp = fopen(path, "w");
|
||||
if (fp == NULL) {
|
||||
g_printerr("[agent] Failed to write discovery file %s\n", path);
|
||||
return;
|
||||
}
|
||||
/* Format: "PID PORT\n" — easy to parse from shell with `read`. */
|
||||
fprintf(fp, "%d %d\n", (int)getpid(), port);
|
||||
fclose(fp);
|
||||
snprintf(g_discovery_path, sizeof(g_discovery_path), "%s", path);
|
||||
g_print("[agent] Discovery file: %s (pid=%d port=%d)\n",
|
||||
path, (int)getpid(), port);
|
||||
}
|
||||
|
||||
static void remove_discovery_file(void) {
|
||||
if (g_discovery_path[0] != '\0') {
|
||||
unlink(g_discovery_path);
|
||||
g_discovery_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Static state ─────────────────────────────────────────────────── */
|
||||
|
||||
static SoupServer *g_server = NULL;
|
||||
@@ -210,8 +250,20 @@ int agent_server_start(int port) {
|
||||
/* Add MCP handler at /mcp. */
|
||||
agent_mcp_register(g_server);
|
||||
|
||||
/* Listen on the specified port (0 = auto-assign). */
|
||||
/* Listen on the specified port. If it's already in use (another browser
|
||||
* instance is running), fall back to port 0 so the OS picks a free port.
|
||||
* The actual bound port is written to a discovery file so browser.sh and
|
||||
* external MCP clients can find it. */
|
||||
soup_server_listen_local(g_server, port, 0, &error);
|
||||
if (error != NULL) {
|
||||
if (port != 0) {
|
||||
g_printerr("[agent] Port %d in use (%s) — falling back to OS auto-assign.\n",
|
||||
port, error->message);
|
||||
g_error_free(error);
|
||||
error = NULL;
|
||||
soup_server_listen_local(g_server, 0, 0, &error);
|
||||
}
|
||||
}
|
||||
if (error != NULL) {
|
||||
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
|
||||
g_error_free(error);
|
||||
@@ -233,6 +285,11 @@ int agent_server_start(int port) {
|
||||
g_running = TRUE;
|
||||
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
|
||||
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
|
||||
if (g_port != port) {
|
||||
g_print("[agent] Note: using auto-assigned port %d (configured port %d was in use).\n",
|
||||
g_port, port);
|
||||
}
|
||||
write_discovery_file(g_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -258,6 +315,7 @@ void agent_server_stop(void) {
|
||||
|
||||
g_running = FALSE;
|
||||
g_port = 0;
|
||||
remove_discovery_file();
|
||||
g_print("[agent] Server stopped\n");
|
||||
}
|
||||
|
||||
|
||||
+32
-12
@@ -12,10 +12,31 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
|
||||
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
|
||||
* on the default context to wait for the result. The key is to acquire
|
||||
* the context before creating the loop so that the WebKit IPC sources
|
||||
* are properly dispatched.
|
||||
* WebKitGTK's evaluate_javascript is async. We run a nested GMainLoop
|
||||
* on the default main context until the JS callback (or a timeout)
|
||||
* quits it.
|
||||
*
|
||||
* This function is routinely called from within a Soup server source
|
||||
* callback (the MCP HTTP handler) that is already dispatching on the
|
||||
* default main context. Running a nested GMainLoop on the same context
|
||||
* is the standard GTK modal pattern (gtk_dialog_run does the same) and
|
||||
* is safe: g_main_loop_run() manages context ownership internally for
|
||||
* the nested run, and the outer loop's dispatch is suspended until the
|
||||
* inner loop quits.
|
||||
*
|
||||
* The PREVIOUS code additionally called g_main_context_acquire()/
|
||||
* g_main_context_release() around the loop. That was the bug: the
|
||||
* default context is already owned by the outer dispatch, so acquire()
|
||||
* fails silently (owner_count is NOT incremented), yet release() then
|
||||
* decrements it unconditionally — producing
|
||||
* "g_main_context_release_unlocked: assertion 'context->owner_count > 0'"
|
||||
* and, combined with the recursive check, a segfault. The fix is simply
|
||||
* to drop the explicit acquire/release and let g_main_loop_run() handle
|
||||
* ownership. We must NOT use a g_main_context_iteration() pump instead,
|
||||
* because that would re-dispatch unrelated ready sources (other Soup
|
||||
* requests, WebKit load events, GTK UI events) re-entrantly while a
|
||||
* tool call is in progress, which can deadlock when an MCP tool runs
|
||||
* during page loading.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
@@ -48,6 +69,7 @@ static gboolean on_js_timeout(gpointer user_data) {
|
||||
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] JS evaluation timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -61,26 +83,24 @@ char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
|
||||
|
||||
js_eval_ctx_t ctx = {0};
|
||||
|
||||
/* Use the default main context for the nested loop. */
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
/* Nested loop on the default context. Do NOT acquire/release the
|
||||
* context here — see the block comment above. */
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
/* Add a timeout source to the same context. */
|
||||
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
|
||||
|
||||
/* Start async evaluation. */
|
||||
/* Start async evaluation. The completion callback fires on the
|
||||
* default main context (where WebKit schedules its async work). */
|
||||
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
|
||||
on_js_finished, &ctx);
|
||||
|
||||
/* Run the nested loop until JS completes or timeout. */
|
||||
/* Run the nested loop until JS completes or the timeout quits it. */
|
||||
g_main_loop_run(ctx.loop);
|
||||
|
||||
/* Cleanup. */
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
+43
-22
@@ -130,9 +130,12 @@ static WebKitWebView *get_active_webview(void) {
|
||||
*
|
||||
* Coordinates from getBoundingClientRect() are relative to the viewport
|
||||
* and match the webview's GdkWindow coordinate space when the webview
|
||||
* fills its parent window. Returns TRUE on success. */
|
||||
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
* fills its parent window. `button` is 1 (left), 2 (middle), or 3 (right).
|
||||
* Returns TRUE on success. */
|
||||
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y,
|
||||
int button) {
|
||||
if (wv == NULL) return FALSE;
|
||||
if (button < 1) button = 1;
|
||||
|
||||
GtkWidget *widget = GTK_WIDGET(wv);
|
||||
GdkWindow *window = gtk_widget_get_window(widget);
|
||||
@@ -150,7 +153,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
press->button.y = y;
|
||||
press->button.axes = NULL;
|
||||
press->button.state = 0; /* no modifiers */
|
||||
press->button.button = 1; /* left button */
|
||||
press->button.button = button;
|
||||
press->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
|
||||
gdk_event_put(press);
|
||||
gdk_event_free(press);
|
||||
@@ -170,7 +173,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
release->button.y = y;
|
||||
release->button.axes = NULL;
|
||||
release->button.state = 0;
|
||||
release->button.button = 1;
|
||||
release->button.button = button;
|
||||
release->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
|
||||
gdk_event_put(release);
|
||||
gdk_event_free(release);
|
||||
@@ -1002,8 +1005,16 @@ static cJSON *tool_eval(cJSON *params) {
|
||||
/* ── Screenshot tool ──────────────────────────────────────────────── */
|
||||
|
||||
/* Context for the async webkit_web_view_snapshot() call. We use a
|
||||
* nested GMainLoop to turn the async API into a synchronous one,
|
||||
* matching the pattern in agent_js_eval_sync(). */
|
||||
* async API into a synchronous one.
|
||||
*
|
||||
* We run a nested GMainLoop on the default main context (the standard
|
||||
* GTK modal pattern) but do NOT call g_main_context_acquire()/
|
||||
* g_main_context_release() around it — the default context is already
|
||||
* owned by the outer dispatch (these tools are invoked from the MCP
|
||||
* HTTP handler), so an explicit acquire fails silently and a matching
|
||||
* release corrupts the owner_count, causing a segfault. The nested
|
||||
* g_main_loop_run() handles ownership internally. See the block comment
|
||||
* in agent_js_eval_sync() for the full rationale. */
|
||||
typedef struct {
|
||||
cairo_surface_t *surface;
|
||||
gboolean done;
|
||||
@@ -1031,6 +1042,7 @@ static gboolean snapshot_timeout_cb(gpointer user_data) {
|
||||
snapshot_ctx_t *ctx = (snapshot_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] screenshot timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -1051,11 +1063,10 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
|
||||
/* Set up the nested GMainLoop to wait for the async snapshot. */
|
||||
/* Nested GMainLoop on the default context. No acquire/release —
|
||||
* see the note on snapshot_ctx_t above. */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -1071,7 +1082,6 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -1166,7 +1176,7 @@ static cJSON *tool_click(cJSON *params) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(sel);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
return make_success(NULL);
|
||||
}
|
||||
/* Fall through to JS fallback if GDK synthesis failed. */
|
||||
@@ -1210,7 +1220,17 @@ static cJSON *tool_click(cJSON *params) {
|
||||
/* click_at — Click at explicit viewport coordinates via GDK event
|
||||
* synthesis. Useful when the agent already knows the target point
|
||||
* (e.g. from a screenshot or get_box result) and wants to bypass
|
||||
* selector resolution entirely. */
|
||||
* selector resolution entirely. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). */
|
||||
static int click_button_from_params(cJSON *params) {
|
||||
cJSON *jb = cJSON_GetObjectItem(params, "button");
|
||||
if (jb && cJSON_IsNumber(jb)) {
|
||||
int b = jb->valueint;
|
||||
if (b >= 1 && b <= 3) return b;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static cJSON *tool_click_at(cJSON *params) {
|
||||
cJSON *jx = cJSON_GetObjectItem(params, "x");
|
||||
cJSON *jy = cJSON_GetObjectItem(params, "y");
|
||||
@@ -1223,8 +1243,9 @@ static cJSON *tool_click_at(cJSON *params) {
|
||||
|
||||
double x = jx->valuedouble;
|
||||
double y = jy->valuedouble;
|
||||
int button = click_button_from_params(params);
|
||||
|
||||
if (!synthesize_click_at(wv, x, y)) {
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
return make_success(NULL);
|
||||
@@ -4022,11 +4043,9 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
/* We don't strictly need the result, but wait for it to complete. */
|
||||
g_free(ann_result);
|
||||
|
||||
/* Step 3: Take the WebKit snapshot (same pattern as tool_screenshot). */
|
||||
/* Step 3: Take the WebKit snapshot (same nested-loop pattern as tool_screenshot). */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -4039,7 +4058,6 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -4581,7 +4599,7 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(box_result);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
g_free(sel);
|
||||
/* Send a success response directly through the
|
||||
* WebSocket connection, matching the async path. */
|
||||
@@ -4619,7 +4637,9 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
|
||||
/* click_at — coordinate-based click via GDK event synthesis (sync).
|
||||
* Takes explicit x/y viewport coordinates and dispatches a real
|
||||
* GDK button press/release. Bypasses selector resolution entirely. */
|
||||
* GDK button press/release. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). Bypasses
|
||||
* selector resolution entirely. */
|
||||
if (conn != NULL && strcmp(tool_name, "click_at") == 0) {
|
||||
cJSON *jx = cJSON_GetObjectItem(params, "x");
|
||||
cJSON *jy = cJSON_GetObjectItem(params, "y");
|
||||
@@ -4630,7 +4650,8 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
if (wv == NULL) return make_error("NO_TAB", "No active tab");
|
||||
double x = jx->valuedouble;
|
||||
double y = jy->valuedouble;
|
||||
if (!synthesize_click_at(wv, x, y)) {
|
||||
int button = click_button_from_params(params);
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
/* Send success response directly through the WebSocket. */
|
||||
|
||||
+105
-88
@@ -8,12 +8,13 @@
|
||||
* - d tag = HMAC-SHA256(hmac_key, path) (deterministic, opaque, 64 hex)
|
||||
* - content = NIP-44 encrypted JSON {"path": ..., "bookmarks": [...]}
|
||||
* - hmac_key = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL)
|
||||
*
|
||||
* On startup, the relay fetch retrieves kind 30003 events, which are
|
||||
* decrypted and loaded into an in-memory trie. Legacy events with plaintext
|
||||
* d tags (from the previous flat-directory implementation) are detected,
|
||||
* migrated to the new HMAC-d format, and their old events are marked for
|
||||
* kind 5 deletion.
|
||||
* d tags (from the previous flat-directory implementation) are IGNORED on
|
||||
* load — they are neither migrated nor re-published. This project is
|
||||
* pre-public and the only user has already re-published every folder with
|
||||
* HMAC d tags, so legacy events are dead data. Skipping them avoids the
|
||||
* per-boot migration/publish storm that earlier versions triggered.
|
||||
*/
|
||||
|
||||
#include "bookmarks.h"
|
||||
@@ -31,16 +32,10 @@
|
||||
|
||||
static nostr_signer_t *g_signer = NULL;
|
||||
static char g_pubkey[65] = {0};
|
||||
static char g_privkey[65] = {0}; /* hex, in-memory only */
|
||||
static int g_have_signer = 0;
|
||||
static int g_have_privkey = 0;
|
||||
|
||||
static bookmark_node_t g_root = {0};
|
||||
|
||||
/* HMAC key cache (32 bytes). Derived once in bookmarks_init from the privkey. */
|
||||
static unsigned char g_hmac_key[32];
|
||||
static int g_have_hmac_key = 0;
|
||||
|
||||
/* Change-notification subscribers. */
|
||||
#define BOOKMARKS_MAX_SUBS 8
|
||||
static bookmarks_changed_cb g_subs[BOOKMARKS_MAX_SUBS];
|
||||
@@ -188,47 +183,41 @@ static void node_remove_child(bookmark_node_t *parent, int idx) {
|
||||
|
||||
/* ── HMAC d-tag derivation ─────────────────────────────────────────── */
|
||||
|
||||
/* Compute the per-user HMAC key from the privkey.
|
||||
* hmac_key = HMAC-SHA256(privkey_bytes, BOOKMARKS_HMAC_KEY_LABEL)
|
||||
* Caches the result in g_hmac_key. Returns 0 on success, -1 on error. */
|
||||
static int compute_hmac_key(void) {
|
||||
if (g_have_hmac_key) return 0;
|
||||
if (!g_have_privkey || g_privkey[0] == '\0') return -1;
|
||||
|
||||
unsigned char priv[32];
|
||||
if (nostr_hex_to_bytes(g_privkey, priv, 32) != 0) {
|
||||
g_printerr("[bookmarks] Invalid privkey hex\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (nostr_hmac_sha256(priv, 32,
|
||||
(const unsigned char *)BOOKMARKS_HMAC_KEY_LABEL,
|
||||
strlen(BOOKMARKS_HMAC_KEY_LABEL),
|
||||
g_hmac_key) != 0) {
|
||||
g_printerr("[bookmarks] HMAC-SHA256 key derivation failed\n");
|
||||
return -1;
|
||||
}
|
||||
g_have_hmac_key = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Compute the opaque d tag for a path: HMAC-SHA256(hmac_key, path) → hex.
|
||||
* Returns a newly allocated 64-char hex string (caller frees), or NULL. */
|
||||
/* Compute the opaque d tag for a path using a single-step HMAC keyed by the
|
||||
* signer's secp256k1 private key:
|
||||
* d = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL + ":" + path) → hex
|
||||
*
|
||||
* This is computed remotely via nostr_signer_derive_hmac, so the privkey
|
||||
* never leaves the signer (n_signer or local). The label prefix provides
|
||||
* domain separation so the same path used by a different application
|
||||
* produces a different d tag.
|
||||
*
|
||||
* Returns a newly allocated 64-char hex string (caller frees), or NULL.
|
||||
*
|
||||
* If no signer is available, falls back to the legacy plaintext d tag
|
||||
* (the path itself) so bookmarks can still be published in read-only mode.
|
||||
* The loader recognises both formats. */
|
||||
static char *path_to_d_tag(const char *path) {
|
||||
if (compute_hmac_key() != 0) return NULL;
|
||||
if (path == NULL) path = "";
|
||||
|
||||
unsigned char mac[32];
|
||||
if (nostr_hmac_sha256(g_hmac_key, 32,
|
||||
(const unsigned char *)path, strlen(path),
|
||||
mac) != 0) {
|
||||
return NULL;
|
||||
if (!g_have_signer || g_signer == NULL) {
|
||||
/* No signer — use the plaintext path as the d tag (read-only mode). */
|
||||
return g_strdup(path);
|
||||
}
|
||||
char *hex = g_malloc(65);
|
||||
if (hex == NULL) return NULL;
|
||||
nostr_bytes_to_hex(mac, 32, hex);
|
||||
hex[64] = '\0';
|
||||
return hex;
|
||||
|
||||
/* Build "LABEL:path" — single-step domain-separated HMAC input. */
|
||||
char *data = g_strdup_printf("%s:%s", BOOKMARKS_HMAC_KEY_LABEL, path);
|
||||
if (data == NULL) return NULL;
|
||||
|
||||
char digest_hex[65];
|
||||
int rc = nostr_signer_derive_hmac(g_signer, data, digest_hex);
|
||||
g_free(data);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
g_printerr("[bookmarks] derive_hmac failed (rc=%d); falling back to plaintext d tag\n", rc);
|
||||
return g_strdup(path);
|
||||
}
|
||||
|
||||
return g_strdup(digest_hex);
|
||||
}
|
||||
|
||||
/* Returns 1 if s is a 64-char lowercase-hex string (i.e. an HMAC d tag),
|
||||
@@ -521,8 +510,7 @@ void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data) {
|
||||
|
||||
/* ── Public API ────────────────────────────────────────────────────── */
|
||||
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
const char *privkey_hex) {
|
||||
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
g_signer = signer;
|
||||
g_have_signer = (signer != NULL);
|
||||
if (pubkey_hex) {
|
||||
@@ -530,14 +518,6 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
} else {
|
||||
g_pubkey[0] = '\0';
|
||||
}
|
||||
if (privkey_hex && privkey_hex[0]) {
|
||||
snprintf(g_privkey, sizeof(g_privkey), "%s", privkey_hex);
|
||||
g_have_privkey = 1;
|
||||
} else {
|
||||
g_privkey[0] = '\0';
|
||||
g_have_privkey = 0;
|
||||
}
|
||||
g_have_hmac_key = 0; /* recompute on first use */
|
||||
|
||||
/* Initialize the root node. */
|
||||
if (g_root.name == NULL) {
|
||||
@@ -562,6 +542,8 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
if (events != NULL) {
|
||||
int n = cJSON_GetArraySize(events);
|
||||
g_print("[bookmarks] Loading %d cached folder event(s)\n", n);
|
||||
int legacy_skipped = 0;
|
||||
int loaded = 0;
|
||||
cJSON *event;
|
||||
cJSON_ArrayForEach(event, events) {
|
||||
/* Get the d tag. */
|
||||
@@ -581,6 +563,25 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events entirely.
|
||||
* Legacy events used the plaintext folder path as the d tag;
|
||||
* the current format uses an opaque HMAC-SHA256 d tag (see
|
||||
* path_to_d_tag). This project is pre-public and the only
|
||||
* user has already re-published every folder with HMAC d
|
||||
* tags, so legacy events are dead data. Skipping them here
|
||||
* (a) avoids the per-boot migration/publish storm that
|
||||
* re-encrypted and re-published every folder on every launch,
|
||||
* and (b) keeps the local SQLite cache from re-triggering
|
||||
* migration via the duplicate legacy rows that earlier
|
||||
* migrations left behind (publish_deletion_for_d stored a
|
||||
* kind 5 tombstone but never deleted the old kind 30003
|
||||
* row). Legacy events are simply not loaded; they are not
|
||||
* deleted from the db or from relays here. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
legacy_skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Decrypt content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
@@ -589,22 +590,14 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) continue;
|
||||
|
||||
/* Determine the path. */
|
||||
/* New format: path is inside the encrypted content. */
|
||||
char *path_from_content = NULL;
|
||||
bookmark_node_t scratch = {0};
|
||||
scratch.name = g_strdup("");
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = NULL;
|
||||
if (is_hmac_d_tag(d_value)) {
|
||||
/* New format: path is inside the encrypted content. */
|
||||
path = path_from_content;
|
||||
} else {
|
||||
/* Legacy: d tag is the plaintext path. */
|
||||
path = d_value;
|
||||
}
|
||||
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -616,14 +609,7 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
scratch.bookmarks = NULL;
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Legacy migration: re-publish with HMAC d tag and delete old. */
|
||||
if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) {
|
||||
g_print("[bookmarks] Migrating legacy folder '%s' to HMAC d tag\n",
|
||||
path);
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
loaded++;
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any
|
||||
@@ -632,12 +618,16 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
free(plaintext);
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
if (legacy_skipped > 0) {
|
||||
g_print("[bookmarks] Skipped %d legacy (non-HMAC) folder event(s)\n",
|
||||
legacy_skipped);
|
||||
}
|
||||
g_print("[bookmarks] Loaded %d HMAC folder event(s)\n", loaded);
|
||||
}
|
||||
}
|
||||
|
||||
g_print("[bookmarks] Initialized: signer=%s privkey=%s\n",
|
||||
g_have_signer ? "yes" : "no",
|
||||
g_have_privkey ? "yes" : "no");
|
||||
g_print("[bookmarks] Initialized: signer=%s\n",
|
||||
g_have_signer ? "yes" : "no");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -654,9 +644,6 @@ void bookmarks_cleanup(void) {
|
||||
g_signer = NULL;
|
||||
g_have_signer = 0;
|
||||
g_pubkey[0] = '\0';
|
||||
g_privkey[0] = '\0';
|
||||
g_have_privkey = 0;
|
||||
g_have_hmac_key = 0;
|
||||
g_sub_count = 0;
|
||||
}
|
||||
|
||||
@@ -724,6 +711,32 @@ int bookmarks_remove(const char *path, const char *url) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
int bookmarks_rename(const char *path, const char *url, const char *new_title) {
|
||||
if (!g_have_signer) {
|
||||
g_printerr("[bookmarks] No signer, cannot rename\n");
|
||||
return -1;
|
||||
}
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
if (url == NULL || url[0] == '\0') return -1;
|
||||
|
||||
bookmark_node_t *leaf = node_find_path(&g_root, path);
|
||||
if (leaf == NULL) return -1;
|
||||
|
||||
int bidx = find_bookmark_in_node(leaf, url);
|
||||
if (bidx < 0) return -1;
|
||||
|
||||
/* Replace the title in place. The URL and added timestamp are
|
||||
* preserved — only the user-editable label changes. */
|
||||
char *old_title = leaf->bookmarks[bidx].title;
|
||||
leaf->bookmarks[bidx].title = g_strdup(new_title ? new_title : "");
|
||||
g_free(old_title);
|
||||
|
||||
g_print("[bookmarks] Renamed bookmark '%s' in '%s'\n", url, path);
|
||||
int rc = publish_node(leaf);
|
||||
notify_changed();
|
||||
return rc;
|
||||
}
|
||||
|
||||
int bookmarks_move(const char *from_path, const char *url, const char *to_path) {
|
||||
if (!g_have_signer) return -1;
|
||||
if (from_path == NULL || from_path[0] == '\0') from_path = "General";
|
||||
@@ -1047,6 +1060,16 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events. See the matching comment in
|
||||
* bookmarks_init() for the full rationale. The event has already been
|
||||
* stored in SQLite by the caller (relay_fetch); we simply do not load
|
||||
* it into the in-memory tree and do not migrate/re-publish it. This
|
||||
* breaks the per-boot migration storm that was triggered every time the
|
||||
* relay fetch re-served a legacy event. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
return 0;
|
||||
@@ -1060,7 +1083,7 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = is_hmac_d_tag(d_value) ? path_from_content : d_value;
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -1072,12 +1095,6 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Legacy migration. */
|
||||
if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) {
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any remaining
|
||||
* bookmarks (already moved to leaf, so NULL). */
|
||||
|
||||
+11
-7
@@ -67,18 +67,16 @@ typedef struct bookmark_node {
|
||||
* 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)
|
||||
* 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
|
||||
* privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive
|
||||
* the HMAC key for opaque d tags. NULL in read-only mode
|
||||
* (existing events can still be loaded from the db, but
|
||||
* no new events can be published).
|
||||
*
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int bookmarks_init(nostr_signer_t *signer,
|
||||
const char *pubkey_hex,
|
||||
const char *privkey_hex);
|
||||
const char *pubkey_hex);
|
||||
|
||||
/* Free the in-memory bookmark tree. Call at shutdown. */
|
||||
void bookmarks_cleanup(void);
|
||||
@@ -107,6 +105,12 @@ int bookmarks_add(const char *path, const char *url, const char *title);
|
||||
* Returns 0 on success, -1 on not found / error. */
|
||||
int bookmarks_remove(const char *path, const char *url);
|
||||
|
||||
/* 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_path, const char *url,
|
||||
|
||||
@@ -109,6 +109,7 @@ enum {
|
||||
OPT_VERBOSE,
|
||||
OPT_QUIET,
|
||||
OPT_HELP,
|
||||
OPT_DOWNLOAD_URL,
|
||||
};
|
||||
|
||||
static struct option long_opts[] = {
|
||||
@@ -143,6 +144,7 @@ static struct option long_opts[] = {
|
||||
{"verbose", no_argument, 0, OPT_VERBOSE},
|
||||
{"quiet", no_argument, 0, OPT_QUIET},
|
||||
{"help", no_argument, 0, OPT_HELP},
|
||||
{"download-url", required_argument, 0, OPT_DOWNLOAD_URL},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
@@ -198,6 +200,7 @@ void cli_args_free(cli_args_t *args) {
|
||||
g_free(args->nsigner_transport);
|
||||
g_free(args->nsigner_device);
|
||||
g_free(args->nsigner_service);
|
||||
g_free(args->download_url);
|
||||
memset(args, 0, sizeof(*args));
|
||||
}
|
||||
|
||||
@@ -356,6 +359,11 @@ int cli_parse(int *argc, char ***argv, cli_args_t *out) {
|
||||
out->want_help = TRUE;
|
||||
return 0;
|
||||
|
||||
case OPT_DOWNLOAD_URL:
|
||||
g_free(out->download_url);
|
||||
out->download_url = xstrdup(optarg);
|
||||
break;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[cli] unhandled option\n");
|
||||
return -1;
|
||||
|
||||
@@ -68,6 +68,10 @@ typedef struct {
|
||||
int verbose; /* --verbose / -v (repeatable count) */
|
||||
gboolean quiet; /* --quiet / -q */
|
||||
|
||||
/* Offline site download (test/diagnostic hook — triggers
|
||||
* site_downloader_start shortly after startup). */
|
||||
char *download_url; /* --download-url <url> */
|
||||
|
||||
/* Exit-request flags set by --help / --version. */
|
||||
gboolean want_help;
|
||||
gboolean want_version;
|
||||
|
||||
@@ -31,6 +31,7 @@ void history_add_titled(const char *url, const char *title) {
|
||||
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
|
||||
|
||||
+23
-1
@@ -514,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),
|
||||
@@ -526,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;
|
||||
}
|
||||
|
||||
+246
-45
@@ -48,6 +48,7 @@
|
||||
#include "agent_server.h"
|
||||
#include "agent_login.h"
|
||||
#include "cli.h"
|
||||
#include "site_downloader.h"
|
||||
#include "db.h"
|
||||
#include "profile.h"
|
||||
#include "relay_fetch.h"
|
||||
@@ -55,6 +56,8 @@
|
||||
#include "agent_conversations.h"
|
||||
#include "agent_skills.h"
|
||||
#include "net_services.h"
|
||||
#include "webkit_data.h"
|
||||
#include "web_context.h"
|
||||
#include "nostr_core/nostr_core.h"
|
||||
|
||||
/* ---- Global state --------------------------------------------------- *
|
||||
@@ -73,12 +76,22 @@ typedef struct {
|
||||
|
||||
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,
|
||||
@@ -112,6 +125,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* If this is a RUNTIME identity switch (past startup) and the
|
||||
* previous context was torn down by identity_teardown_web_state(),
|
||||
* rebuild a fresh per-user context for the new identity now. On the
|
||||
* first login (before main() calls tab_manager_init), g_post_startup
|
||||
* is FALSE and web_context_get() is NULL, so main() builds the
|
||||
* context after login — we skip here to avoid a double-build. */
|
||||
if (g_post_startup && web_context_get() == NULL) {
|
||||
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user so they don't stare at
|
||||
* an empty window after the teardown closed all tabs. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to rebuild web context\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_clear_signer(void) {
|
||||
@@ -134,12 +164,80 @@ const char *app_get_pubkey_hex(void) { return g_state.pubkey_hex; }
|
||||
key_store_method_t app_get_method(void) { return g_state.method; }
|
||||
gboolean app_get_readonly(void) { return g_state.readonly; }
|
||||
|
||||
/* Idle callback for the --download-url diagnostic flag: triggers the
|
||||
* offline site downloader on the main thread shortly after startup. */
|
||||
static gboolean dl_test_trigger(gpointer data) {
|
||||
const char *url = (const char *)data;
|
||||
GtkWindow *win = gtk_window_get_focus(GTK_WINDOW(g_window)) ?
|
||||
g_window : NULL;
|
||||
/* g_window may not be set yet in some early-trigger scenarios; fall
|
||||
* back to NULL (unparented dialog). */
|
||||
site_downloader_start(url, win);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ---- Menu proxy functions ------------------------------------------- *
|
||||
* These wrap the app_state_t-aware callbacks so they match GTK signal
|
||||
* handler signatures and can be called from tab_manager.c's hamburger
|
||||
* menu builder.
|
||||
*/
|
||||
|
||||
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
|
||||
* Called on logout and identity switch. Closes all tabs, destroys the
|
||||
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
|
||||
* with the previous user's sovereign://agents/chat session), and wipes
|
||||
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
|
||||
* service workers, favicons) from the shared WebKitWebsiteDataManager.
|
||||
*
|
||||
* Without this, the next user inherits the previous user's web session:
|
||||
* cookies identify you to web pages, localStorage holds per-site state,
|
||||
* and the live tabs keep the previous user's DOM in memory. See
|
||||
* plans/webkit-data-isolation.md for the full rationale.
|
||||
*
|
||||
* This must run on the GTK main thread. It pumps a nested main loop
|
||||
* briefly while webkit_website_data_manager_clear() completes.
|
||||
*/
|
||||
void identity_teardown_web_state(void) {
|
||||
/* 1. Close all tabs. This destroys the webviews and drops their
|
||||
* in-memory DOM/localStorage. Must happen before the context
|
||||
* teardown so no webviews hold dangling references to the old
|
||||
* context. Suppress the normal "last tab closed → quit the app"
|
||||
* behavior so the browser stays alive for the next user; the
|
||||
* caller opens a fresh tab after the switch. */
|
||||
tab_manager_set_suppress_quit_on_last_tab(TRUE);
|
||||
tab_manager_close_all();
|
||||
tab_manager_set_suppress_quit_on_last_tab(FALSE);
|
||||
|
||||
/* 2. Destroy the agent chat sidebar webviews in every window. The
|
||||
* sidebar webview is outside the notebook so tab_manager_close_all
|
||||
* does not touch it; it would otherwise keep a reference to the
|
||||
* old context (and the previous user's chat session) alive. */
|
||||
tab_manager_destroy_sidebar_webviews();
|
||||
|
||||
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
|
||||
* context and its per-user WebKitWebsiteDataManager. With Phase A
|
||||
* isolation, each user has their own data manager rooted at
|
||||
* profiles/<pubkey>/webkit/, so tearing down the context is what
|
||||
* actually isolates the users — the next build creates a fresh
|
||||
* data manager for the new user. The Phase 0 webkit_data_clear_all
|
||||
* wipe is no longer needed for isolation (the data manager is
|
||||
* per-user), but we keep it as a defense-in-depth safety net in
|
||||
* case any process-global WebKit state survived the context
|
||||
* teardown. */
|
||||
WebKitWebContext *ctx = web_context_get();
|
||||
if (ctx != NULL) {
|
||||
/* Defense-in-depth: clear the data manager before tearing down
|
||||
* the context. This catches any process-global caches that
|
||||
* outlive the context. */
|
||||
int rc = webkit_data_clear_all(ctx, FALSE);
|
||||
if (rc != 0) {
|
||||
g_printerr("[identity] webkit_data_clear_all returned %d "
|
||||
"(continuing with context teardown)\n", rc);
|
||||
}
|
||||
web_context_teardown();
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
@@ -147,6 +245,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
login_result_t result;
|
||||
if (login_dialog_run(window, &result) == 0) {
|
||||
/* Tear down the previous user's web state BEFORE installing the
|
||||
* new signer, so the new user starts with a clean web session. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
}
|
||||
@@ -166,6 +268,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
|
||||
switch_to_user_db(g_state.pubkey_hex);
|
||||
}
|
||||
|
||||
/* Build a fresh per-user WebKitWebContext for the new identity
|
||||
* (with its own per-user data manager) and re-register the
|
||||
* sovereign://, nostr://, tor:// schemes on it. The old context
|
||||
* was torn down by identity_teardown_web_state() above. */
|
||||
if (build_context_for_current_user() == NULL) {
|
||||
g_printerr("[identity] Failed to build context for new user — "
|
||||
"browser will have no web views\n");
|
||||
}
|
||||
|
||||
/* Open a fresh tab for the new user so they don't stare at an
|
||||
* empty window after the teardown closed all the previous user's
|
||||
* tabs. Uses the new user's per-user new_tab_url setting. */
|
||||
const char *url = settings_get()->new_tab_url;
|
||||
tab_manager_new_tab(url);
|
||||
|
||||
g_print("[identity] switched: method=%d pubkey=%s\n",
|
||||
g_state.method, g_state.pubkey_hex);
|
||||
}
|
||||
@@ -185,15 +302,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
|
||||
|
||||
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
GtkWindow *window = GTK_WINDOW(data);
|
||||
|
||||
/* Tear down the current user's web state (closes all tabs, destroys
|
||||
* the sidebar webviews, wipes cookies/cache/localStorage/service
|
||||
* workers/favicons) BEFORE clearing the signer, so the next login
|
||||
* starts with a clean web session. See plans/webkit-data-isolation.md. */
|
||||
identity_teardown_web_state();
|
||||
|
||||
if (g_state.signer) {
|
||||
nostr_signer_free(g_state.signer);
|
||||
g_state.signer = NULL;
|
||||
}
|
||||
g_state.pubkey_hex[0] = '\0';
|
||||
g_state.privkey_hex[0] = '\0';
|
||||
g_state.method = KEY_STORE_METHOD_NONE;
|
||||
g_state.readonly = FALSE;
|
||||
g_logged_in = FALSE;
|
||||
|
||||
settings_sync_set_signer(NULL, NULL);
|
||||
agent_conversations_set_signer(NULL, NULL);
|
||||
|
||||
/* Reset the per-user db tracking so the next login re-opens the
|
||||
* new user's browser.db rather than assuming we're already on it. */
|
||||
g_current_profile_db[0] = '\0';
|
||||
|
||||
g_print("[identity] logged out\n");
|
||||
|
||||
/* Re-show the login dialog so the user (or agent) can log in as a
|
||||
* different identity. The dialog runs a nested main loop, so the
|
||||
* agent server stays live and an agent can log in via MCP while the
|
||||
* dialog is showing. On success, do_login() installs the new signer
|
||||
* and switch_to_user_db() opens the new user's profile. */
|
||||
if (window != NULL) {
|
||||
if (do_login(window) == 0) {
|
||||
/* Build a fresh per-user WebKitWebContext for the new
|
||||
* identity. The old context was torn down above. For
|
||||
* --no-login mode this builds an ephemeral context. */
|
||||
if (build_context_for_current_user() != NULL) {
|
||||
/* Open a fresh tab for the new user. */
|
||||
tab_manager_new_tab(settings_get()->new_tab_url);
|
||||
} else {
|
||||
g_printerr("[identity] Failed to build context after re-login\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
|
||||
@@ -474,6 +627,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
tab_manager_toggle_sidebar();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_TOGGLE_TOOLBARS:
|
||||
tab_manager_toggle_toolbars();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_ZOOM_IN:
|
||||
tab_manager_zoom_in();
|
||||
return TRUE;
|
||||
@@ -679,9 +836,9 @@ static int do_login(GtkWindow *parent) {
|
||||
|
||||
/* Track the currently-open per-user db path so we can skip re-opening
|
||||
* the same database (e.g. when app_set_signer() is called during the
|
||||
* login dialog and then main() calls switch_to_user_db() again). */
|
||||
static char g_current_profile_db[512] = "";
|
||||
|
||||
* login dialog and then main() calls switch_to_user_db() again).
|
||||
* The actual definition is near the top of this file (forward-declared
|
||||
* before the menu proxies that reset it on logout). */
|
||||
static int switch_to_user_db(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[profile] No pubkey, staying on global.db\n");
|
||||
@@ -736,6 +893,52 @@ static int switch_to_user_db(const char *pubkey_hex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Per-user WebKit context build helper --------------------------- *
|
||||
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
|
||||
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
|
||||
* and updates tab_manager so new tabs use the new context. Called from:
|
||||
* - main() after login, before creating tabs
|
||||
* - app_menu_switch_identity_proxy() after tearing down the old context
|
||||
* - app_menu_logout_proxy() after re-login
|
||||
*
|
||||
* The caller must have torn down the previous context (via
|
||||
* web_context_teardown()) and closed all tabs/sidebar webviews first.
|
||||
* Returns the new context, or NULL on failure (the caller may fall back
|
||||
* to a blank window or exit).
|
||||
*/
|
||||
static WebKitWebContext *build_context_for_current_user(void) {
|
||||
WebKitWebContext *web_ctx;
|
||||
if (g_state.pubkey_hex[0] != '\0') {
|
||||
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
|
||||
} else {
|
||||
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
|
||||
* persistence. See plans/webkit-data-isolation.md. */
|
||||
web_ctx = web_context_build_ephemeral();
|
||||
}
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Failed to build WebKit context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Register the sovereign:// URI scheme for the window.nostr bridge.
|
||||
* nostr_bridge_set_signer() was already called by the login path,
|
||||
* so the bridge will use the current signer. */
|
||||
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
|
||||
/* Register nostr:// and tor:// entity-page schemes on this context.
|
||||
* These are per-context registrations, so they must be re-registered
|
||||
* on every fresh context. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
tor_scheme_register(web_ctx);
|
||||
|
||||
/* Point tab_manager at the new context so subsequent new tabs (and
|
||||
* the sidebar webview) are created from it. */
|
||||
tab_manager_set_context(web_ctx);
|
||||
|
||||
return web_ctx;
|
||||
}
|
||||
|
||||
/* ---- Main ----------------------------------------------------------- */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@@ -945,8 +1148,7 @@ int main(int argc, char **argv) {
|
||||
* from SQLite and decrypts them. In no-login/read-only mode, the
|
||||
* signer is NULL so bookmarks are read-only. */
|
||||
bookmarks_init(g_state.signer,
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL,
|
||||
g_state.privkey_hex[0] ? g_state.privkey_hex : NULL);
|
||||
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Initialize the NIP-78 settings sync module. In no-login/read-only
|
||||
* mode, the signer is NULL so settings are local-only (not synced). */
|
||||
@@ -971,50 +1173,35 @@ int main(int argc, char **argv) {
|
||||
* when the relay fetch completes and the kind 0 event is stored. */
|
||||
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
|
||||
|
||||
/* Use the default WebKitWebContext — it comes with proper networking
|
||||
* (cookies, cache, soup session) that a freshly created context lacks.
|
||||
* All tabs will create webviews from this shared context. */
|
||||
WebKitWebContext *web_ctx = webkit_web_context_get_default();
|
||||
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
|
||||
* Build a fresh WebKitWebContext with a per-user
|
||||
* WebKitWebsiteDataManager rooted at
|
||||
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
|
||||
* data manager for --no-login mode). This gives true per-identity
|
||||
* isolation of cookies, cache, localStorage, IndexedDB, service
|
||||
* workers, and favicons. The helper also registers the sovereign://,
|
||||
* nostr://, tor:// URI schemes on the new context and points
|
||||
* tab_manager at it. */
|
||||
WebKitWebContext *web_ctx = build_context_for_current_user();
|
||||
if (web_ctx == NULL) {
|
||||
g_printerr("[main] Cannot continue without a WebKit context.\n");
|
||||
agent_server_stop();
|
||||
nostr_cleanup();
|
||||
cli_args_free(&cli);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
/* ── Security strip: disable web security restrictions ─────── */
|
||||
/* Fetch the security manager for the new context — used below to
|
||||
* wire the sovereign://security page to the first tab's settings. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(web_ctx);
|
||||
/* Register sovereign:// as secure only. Do NOT register it as "local"
|
||||
* (WebKit blocks fetch from https to local origins) and do NOT register
|
||||
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
|
||||
* which we can't set with the basic finish API). Without these, WebKit
|
||||
* treats sovereign:// as a simple secure scheme with no CORS checks. */
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
WebKitWebsiteDataManager *data_mgr =
|
||||
webkit_web_context_get_website_data_manager(web_ctx);
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* Enable the favicon database so WebKitGTK automatically fetches and
|
||||
* caches favicons for visited pages. Without this, the
|
||||
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
|
||||
* always returns NULL. Passing NULL for the directory uses WebKit's
|
||||
* default cache location. */
|
||||
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
|
||||
g_print("[main] Favicon database enabled\n");
|
||||
|
||||
/* Initialize browser-managed/attached network services after login and
|
||||
* after the shared WebKit context/data manager have been configured. */
|
||||
/* Initialize browser-managed/attached network services after the
|
||||
* per-user context has been built. net_services_refresh_proxy()
|
||||
* now uses web_context_get() so proxy settings apply to the
|
||||
* per-user context. */
|
||||
net_services_init();
|
||||
|
||||
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
|
||||
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
|
||||
g_state.readonly);
|
||||
|
||||
/* Register nostr:// entity pages before creating any webviews. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
tor_scheme_register(web_ctx);
|
||||
|
||||
/* Vertical box: the tab manager's notebook fills the window. */
|
||||
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_container_add(GTK_CONTAINER(window), vbox);
|
||||
@@ -1022,6 +1209,11 @@ int main(int argc, char **argv) {
|
||||
/* Initialize the tab manager. */
|
||||
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
|
||||
|
||||
/* Mark startup complete. From now on, app_set_signer() (called by
|
||||
* agent login / switch_identity) will rebuild the per-user web
|
||||
* context if it was torn down, since main() won't build it again. */
|
||||
g_post_startup = TRUE;
|
||||
|
||||
/* Decide whether to restore the previous session or open CLI URLs.
|
||||
*
|
||||
* Precedence:
|
||||
@@ -1059,6 +1251,15 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Diagnostic/test hook: --download-url triggers the offline site
|
||||
* downloader shortly after startup (same code path as the tab
|
||||
* context menu's "Download Website"). */
|
||||
if (cli.download_url != NULL && cli.download_url[0] != '\0') {
|
||||
char *dl_url = g_strdup(cli.download_url);
|
||||
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
|
||||
(GSourceFunc)dl_test_trigger, dl_url, g_free);
|
||||
}
|
||||
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
|
||||
+39
-4
@@ -473,12 +473,15 @@ static const char *sovereign_page_css(void) {
|
||||
" min-width: 200px; }\n"
|
||||
" input:focus, select:focus, textarea:focus {\n"
|
||||
" border-color: var(--accent); outline: none; }\n"
|
||||
" .btn, .save-btn { background: var(--bg); color: var(--primary);\n"
|
||||
" border: 1px solid var(--primary); border-radius: var(--radius);\n"
|
||||
" .btn, .save-btn, button.btn { background: var(--bg); color: var(--primary);\n"
|
||||
" -webkit-appearance: none !important; appearance: none !important;\n"
|
||||
" border-width: 1px !important; border-style: solid !important;\n"
|
||||
" border-color: var(--primary) !important; border-radius: var(--radius);\n"
|
||||
" padding: 6px 16px; cursor: pointer; font-family: var(--font);\n"
|
||||
" font-size: 13px; font-weight: bold; margin-left: 8px;\n"
|
||||
" display: inline-block; text-decoration: none;\n"
|
||||
" transition: border-color 0.2s, background 0.2s, color 0.2s; }\n"
|
||||
" .btn:hover, .save-btn:hover { border-color: var(--accent); }\n"
|
||||
" .btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }\n"
|
||||
" .btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }\n"
|
||||
" .btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
|
||||
" .btn-del { border-color: var(--accent); color: var(--accent); }\n"
|
||||
@@ -1570,6 +1573,34 @@ static void handle_bookmarks_delete(WebKitURISchemeRequest *request,
|
||||
g_free(dir); g_free(url);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/rename?dir=...&url=...&title=...
|
||||
* Renames a bookmark's user-editable title in place. */
|
||||
static void handle_bookmarks_rename(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
char *dir = query_param(query, "dir");
|
||||
char *url = query_param(query, "url");
|
||||
char *title = query_param(query, "title");
|
||||
|
||||
if (!url || url[0] == '\0') {
|
||||
respond_error_json(request, -1, "Missing url parameter");
|
||||
g_free(dir); g_free(url); g_free(title);
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = bookmarks_rename(dir ? dir : "General", url, title ? title : "");
|
||||
if (rc != 0) {
|
||||
respond_error_json(request, -1, "Failed to rename bookmark");
|
||||
} else {
|
||||
cJSON *result = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result, "status", "renamed");
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
cJSON_Delete(result);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
g_free(dir); g_free(url); g_free(title);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/createdir?name=... */
|
||||
static void handle_bookmarks_createdir(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
@@ -1815,7 +1846,7 @@ static void handle_agents_page(WebKitURISchemeRequest *request) {
|
||||
" <button class='save-btn' onclick=\"save('agent.llm_system_prompt')\">Save</button></div>\n"
|
||||
"</div>\n"
|
||||
"\n"
|
||||
"<p><a href='sovereign://agents/chat'>Open Agent Chat</a></p>\n"
|
||||
"<p><a href='sovereign://agents/chat' target='_blank'>Open Agent Chat</a></p>\n"
|
||||
"\n"
|
||||
"<div id='status' class='status'></div>\n"
|
||||
"\n"
|
||||
@@ -4103,6 +4134,10 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
|
||||
handle_bookmarks_renamedir(request, uri + 32);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://bookmarks/rename?", 29) == 0) {
|
||||
handle_bookmarks_rename(request, uri + 29);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://settings requests. The page is served from the
|
||||
* embedded file www/settings.html (Phase 3 migration). */
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -126,6 +126,14 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
s->fips_control_socket[0] = '\0';
|
||||
snprintf(s->fips_config_dir, sizeof(s->fips_config_dir),
|
||||
"~/.sovereign_browser/fips");
|
||||
|
||||
s->offline_site_max_pages = SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT;
|
||||
s->offline_site_request_delay_ms = SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT;
|
||||
|
||||
/* Perf probe is OFF by default — its instrumentation overhead
|
||||
* (patched addEventListener/setTimeout/setInterval/XHR + a permanent
|
||||
* rAF loop) breaks scrolling on listener-heavy pages. */
|
||||
s->perf_probe_enabled = FALSE;
|
||||
}
|
||||
|
||||
/* ── Parsing helpers ──────────────────────────────────────────────── */
|
||||
@@ -317,6 +325,20 @@ void settings_load_user(void) {
|
||||
val = db_kv_get("fips.config_dir");
|
||||
if (val) snprintf(g_settings.fips_config_dir, sizeof(g_settings.fips_config_dir), "%s", val);
|
||||
|
||||
val = db_kv_get("offline_site.max_pages");
|
||||
if (val) {
|
||||
g_settings.offline_site_max_pages = parse_int(val, g_settings.offline_site_max_pages);
|
||||
if (g_settings.offline_site_max_pages < 1) g_settings.offline_site_max_pages = 1;
|
||||
}
|
||||
val = db_kv_get("offline_site.request_delay_ms");
|
||||
if (val) {
|
||||
g_settings.offline_site_request_delay_ms = parse_int(val, g_settings.offline_site_request_delay_ms);
|
||||
if (g_settings.offline_site_request_delay_ms < 0) g_settings.offline_site_request_delay_ms = 0;
|
||||
}
|
||||
|
||||
val = db_kv_get("perf_probe.enabled");
|
||||
if (val) g_settings.perf_probe_enabled = parse_bool(val, g_settings.perf_probe_enabled);
|
||||
|
||||
/* Agent LLM provider settings — these are the "resolved" values
|
||||
* (active provider's base_url + api_key + selected model). They are
|
||||
* populated from db_kv here for local persistence, and overwritten
|
||||
@@ -483,6 +505,13 @@ void settings_save_user(void) {
|
||||
db_kv_set("fips.control_socket", g_settings.fips_control_socket);
|
||||
db_kv_set("fips.config_dir", g_settings.fips_config_dir);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_max_pages);
|
||||
db_kv_set("offline_site.max_pages", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_request_delay_ms);
|
||||
db_kv_set("offline_site.request_delay_ms", buf);
|
||||
|
||||
db_kv_set("perf_probe.enabled", g_settings.perf_probe_enabled ? "true" : "false");
|
||||
|
||||
/* Agent LLM provider settings — resolved values (active provider). */
|
||||
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
|
||||
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
|
||||
|
||||
@@ -34,6 +34,8 @@ extern "C" {
|
||||
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
|
||||
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
|
||||
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
|
||||
#define SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT 500
|
||||
#define SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT 100
|
||||
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
|
||||
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
|
||||
|
||||
@@ -121,6 +123,21 @@ typedef struct {
|
||||
char fips_binary_path[256];
|
||||
char fips_control_socket[512];
|
||||
char fips_config_dir[512];
|
||||
|
||||
/* Offline website downloader (right-click tab → Download Website). */
|
||||
int offline_site_max_pages; /* safety cap on pages crawled */
|
||||
int offline_site_request_delay_ms; /* politeness delay between requests */
|
||||
|
||||
/* Per-tab performance probe (sovereign://processes). The probe patches
|
||||
* addEventListener / setTimeout / setInterval / XHR and runs a permanent
|
||||
* requestAnimationFrame loop on every page, which adds main-thread
|
||||
* overhead. On listener-heavy pages (e.g. NIP-17 DM clients that
|
||||
* re-register many listeners per re-render) this overhead is enough to
|
||||
* starve the main thread and break scrolling — the page's own
|
||||
* scroll-to-bottom rAF wins the race and yanks the view to the bottom.
|
||||
* Default OFF so normal browsing is not penalized; enable explicitly when
|
||||
* you want the processes/perf view. */
|
||||
gboolean perf_probe_enabled;
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
|
||||
@@ -100,6 +100,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"toggle_sidebar", "Toggle agent sidebar",
|
||||
"Show or hide the agent chat sidebar", "<Control><Shift>a"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_TOOLBARS] = {
|
||||
"toggle_toolbars", "Toggle menu bars",
|
||||
"Show or hide the tab strip, toolbars, and bookmark bars in the active window",
|
||||
"<Control><Shift>m"
|
||||
},
|
||||
[SHORTCUT_ZOOM_IN] = {
|
||||
"zoom_in", "Zoom in",
|
||||
"Increase the zoom level of the active tab", "<Control>plus"
|
||||
|
||||
@@ -46,6 +46,7 @@ typedef enum {
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_TOGGLE_SIDEBAR,
|
||||
SHORTCUT_TOGGLE_TOOLBARS,
|
||||
SHORTCUT_ZOOM_IN,
|
||||
SHORTCUT_ZOOM_OUT,
|
||||
SHORTCUT_ZOOM_RESET,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* site_downloader.h — download a website for offline viewing
|
||||
*
|
||||
* Triggered from the tab right-click context menu ("Download Website").
|
||||
* Crawls the current site (same-origin, bounded by a page-count cap),
|
||||
* saves all pages and assets to a per-profile directory, rewrites links
|
||||
* to relative paths, and opens the offline copy in a new tab via file://.
|
||||
*
|
||||
* See plans/download-website-offline.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef SITE_DOWNLOADER_H
|
||||
#define SITE_DOWNLOADER_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Start downloading the website at start_url.
|
||||
*
|
||||
* Shows a modal progress dialog (pages/files counts + progress bar +
|
||||
* Cancel button) parented to `parent`. A background thread runs a BFS
|
||||
* crawl over same-origin links using libsoup, saving pages and assets
|
||||
* under ~/.sovereign_browser/profiles/<pubkey>/offline_sites/<host>/
|
||||
* (or the global ~/.sovereign_browser/offline_sites/<host>/ when not
|
||||
* logged in). On completion or cancel, the progress dialog is closed
|
||||
* and the offline index.html is opened in a new tab.
|
||||
*
|
||||
* No-op (shows an error dialog) for non-http(s) URLs.
|
||||
*
|
||||
* start_url — absolute http:// or https:// URL of the page to download
|
||||
* parent — the GtkWindow to parent the progress dialog to (may be NULL)
|
||||
*/
|
||||
void site_downloader_start(const char *start_url, GtkWindow *parent);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SITE_DOWNLOADER_H */
|
||||
+377
-48
@@ -21,6 +21,7 @@
|
||||
#include "db.h"
|
||||
#include "net_services.h"
|
||||
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
|
||||
#include "site_downloader.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
@@ -90,6 +91,8 @@ typedef struct {
|
||||
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
|
||||
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
|
||||
gboolean sidebar_visible;
|
||||
gboolean toolbars_visible; /* per-window menu-bar (tab strip +
|
||||
* toolbars + bookmark bars) visibility */
|
||||
} window_state_t;
|
||||
|
||||
/* ── Static state ─────────────────────────────────────────────────── */
|
||||
@@ -136,14 +139,32 @@ static GtkWidget *g_target_notebook = NULL;
|
||||
* When non-NULL, tab_create() creates the webview with
|
||||
* webkit_web_view_new_with_related_view() so it shares the parent's
|
||||
* WebProcess and window features (avoids the WindowFeatures assertion
|
||||
* crash). Used by tab_manager_new_window(). */
|
||||
* crash). Used by tab_manager_new_window() and on_create_webview(). */
|
||||
static WebKitWebView *g_target_related_view = NULL;
|
||||
|
||||
/* When TRUE, tab_create() skips the explicit webkit_web_view_load_uri()
|
||||
* call. This is only needed in the "create" signal path (on_create_webview),
|
||||
* where WebKit already has a pending NavigationAction with WindowFeatures
|
||||
* that it will apply to the returned webview itself — an explicit load
|
||||
* here would race with WebKit's own navigation setup and trigger the
|
||||
* std::optional<WindowFeatures> assertion crash.
|
||||
*
|
||||
* tab_manager_new_window() does NOT set this: it creates a fresh webview
|
||||
* with no pending WebKit navigation, so it MUST load the URL explicitly. */
|
||||
static gboolean g_target_skip_load = FALSE;
|
||||
|
||||
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
|
||||
static tab_info_t **g_tabs = NULL;
|
||||
static int g_tab_count = 0;
|
||||
static int g_tab_cap = 0;
|
||||
|
||||
/* When TRUE, closing the last tab of the main window does NOT quit the
|
||||
* app. Used by identity_teardown_web_state() so it can close all tabs
|
||||
* (to drop the previous user's live webviews) without exiting the
|
||||
* browser during a logout / identity switch. The caller re-opens a
|
||||
* fresh tab for the new user afterwards. */
|
||||
static gboolean g_suppress_quit_on_last_tab = FALSE;
|
||||
|
||||
/* ── Forward declarations ─────────────────────────────────────────── */
|
||||
|
||||
/* Per-window sidebar helpers (defined in the sidebar section at the
|
||||
@@ -161,6 +182,7 @@ static int tab_array_find(tab_info_t *tab);
|
||||
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
|
||||
static char *normalize_url(const char *input);
|
||||
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
|
||||
static void on_tab_download_website(GtkMenuItem *item, gpointer data);
|
||||
static void on_avatar_clicked(GtkButton *btn, gpointer data);
|
||||
static GtkWidget *tab_manager_new_window(const char *url,
|
||||
WebKitWebView *related_view);
|
||||
@@ -790,6 +812,15 @@ static gboolean on_notebook_button_press(GtkWidget *widget,
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_dl =
|
||||
gtk_menu_item_new_with_label("Download Website");
|
||||
g_signal_connect(item_dl, "activate",
|
||||
G_CALLBACK(on_tab_download_website), tab);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||||
|
||||
gtk_widget_show_all(menu);
|
||||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||||
return TRUE;
|
||||
@@ -868,6 +899,15 @@ static gboolean on_tab_label_button_press(GtkWidget *widget,
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_dl =
|
||||
gtk_menu_item_new_with_label("Download Website");
|
||||
g_signal_connect(item_dl, "activate",
|
||||
G_CALLBACK(on_tab_download_website), tab);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||||
|
||||
gtk_widget_show_all(menu);
|
||||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||||
return TRUE;
|
||||
@@ -883,6 +923,19 @@ static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
|
||||
tab_manager_new_tab(NULL);
|
||||
}
|
||||
|
||||
/* "Download Website" — crawl the tab's current URL for offline viewing.
|
||||
* The tab_info_t is passed as data; we read its current_url and hand off
|
||||
* to site_downloader_start, which shows a progress dialog and opens the
|
||||
* offline copy in a new tab when done. */
|
||||
static void on_tab_download_website(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
tab_info_t *tab = (tab_info_t *)data;
|
||||
if (tab == NULL) return;
|
||||
if (tab->current_url[0] == '\0') return;
|
||||
GtkWindow *parent = g_active_window ? g_active_window : g_window;
|
||||
site_downloader_start(tab->current_url, parent);
|
||||
}
|
||||
|
||||
/* ── Favicon handling ─────────────────────────────────────────────── *
|
||||
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
|
||||
* automatically fetches and caches favicons as pages load. It must be
|
||||
@@ -1110,9 +1163,17 @@ static GtkWidget *on_create_webview(WebKitWebView *webview,
|
||||
if (uri && uri[0]) {
|
||||
/* Set the related view so tab_create() uses
|
||||
* webkit_web_view_new_with_related_view() — shares the parent's
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. */
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. Also
|
||||
* set g_target_skip_load: WebKit already has a pending
|
||||
* NavigationAction with WindowFeatures that it will apply to the
|
||||
* returned webview itself, so tab_create() must NOT call
|
||||
* webkit_web_view_load_uri() (that would race with WebKit's own
|
||||
* navigation setup and trigger the std::optional<WindowFeatures>
|
||||
* assertion crash). */
|
||||
g_target_related_view = webview;
|
||||
g_target_skip_load = TRUE;
|
||||
int idx = tab_manager_new_tab(uri);
|
||||
g_target_skip_load = FALSE;
|
||||
g_target_related_view = NULL;
|
||||
|
||||
if (idx >= 0) {
|
||||
@@ -1268,8 +1329,14 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting: the probe patches
|
||||
* addEventListener/setTimeout/setInterval/XHR and runs a permanent
|
||||
* rAF loop, which adds enough main-thread overhead to break
|
||||
* scrolling on listener-heavy pages. Default OFF. */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
const browser_settings_t *s = settings_get();
|
||||
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
|
||||
@@ -1309,6 +1376,7 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
ws.sidebar_container = sidebar_container;
|
||||
ws.sidebar_webview = NULL;
|
||||
ws.sidebar_visible = FALSE;
|
||||
ws.toolbars_visible = TRUE;
|
||||
g_array_append_val(g_aux_windows, ws);
|
||||
|
||||
/* This new window is now the active window. */
|
||||
@@ -1719,6 +1787,14 @@ static void on_title_changed(WebKitWebView *webview,
|
||||
|
||||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||||
|
||||
/* Context passed to on_context_open_in_new_window(): the link URL and the
|
||||
* webview that was right-clicked (used as the related view so the new
|
||||
* window shares the parent's WebProcess). */
|
||||
struct new_window_ctx {
|
||||
char *url;
|
||||
WebKitWebView *related_view;
|
||||
};
|
||||
|
||||
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
|
||||
static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
@@ -1732,6 +1808,47 @@ static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
g_free(url);
|
||||
}
|
||||
|
||||
/* Idle callback that actually creates the new window. We defer the
|
||||
* tab_manager_new_window() call out of the GAction "activate" handler
|
||||
* because that runs inside the GtkMenu's signal emission / GSource
|
||||
* teardown. Creating a new top-level window (which calls
|
||||
* gtk_widget_show_all + gtk_window_present and thus processes pending
|
||||
* events) from within that handler caused GLib-CRITICAL
|
||||
* g_source_ref/g_source_unref assertion failures as the menu's own
|
||||
* GSources were being released concurrently. Running on the next idle
|
||||
* tick lets the menu fully close first. */
|
||||
static gboolean on_idle_open_in_new_window(gpointer user_data) {
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx && ctx->url && ctx->url[0]) {
|
||||
tab_manager_new_window(ctx->url, ctx->related_view);
|
||||
}
|
||||
if (ctx) {
|
||||
g_free(ctx->url);
|
||||
g_slice_free(struct new_window_ctx, ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE; /* run once */
|
||||
}
|
||||
|
||||
/* Callback for "Open Link in New Window". Opens the link URL in a brand
|
||||
* new top-level GtkWindow (via tab_manager_new_window) instead of a new
|
||||
* tab. The related view (the webview that was right-clicked) is passed so
|
||||
* the new window shares the parent's WebProcess. The actual window
|
||||
* creation is deferred to an idle handler (see on_idle_open_in_new_window)
|
||||
* to avoid GLib-CRITICAL source-refcount warnings from creating the
|
||||
* window inside the menu's activate callback. */
|
||||
static void on_context_open_in_new_window(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
gpointer user_data) {
|
||||
(void)action;
|
||||
(void)parameter;
|
||||
/* Steal the ctx and defer to idle — do NOT free it here; the idle
|
||||
* callback owns and frees it. */
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx) {
|
||||
g_idle_add(on_idle_open_in_new_window, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu signal handler for the webview. Appends "Open in New Tab"
|
||||
* to the default WebKit context menu — either "Open Link in New Tab" if
|
||||
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
|
||||
@@ -1781,21 +1898,63 @@ static gboolean on_webview_context_menu(WebKitWebView *webview,
|
||||
NULL);
|
||||
|
||||
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
|
||||
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
|
||||
* Tab" at position 1 — between "Open Link" and "Open Link in New
|
||||
* Window" — so the order is:
|
||||
* then "Open Link in New Window" (pos 1). We replace that default
|
||||
* "Open Link in New Window" item with our own that actually opens a
|
||||
* real top-level GtkWindow (WebKit's default fires the "create"
|
||||
* signal, which our on_create_webview handler routes to a new *tab*,
|
||||
* so without this replacement "Open in New Window" would wrongly open
|
||||
* a tab). We then insert "Open Link in New Tab" at position 1, so the
|
||||
* final order is:
|
||||
* Open Link
|
||||
* Open Link in New Tab
|
||||
* Open Link in New Window
|
||||
* Open Link in New Window (ours — real new window)
|
||||
*
|
||||
* For page background: insert at position 0 (top). */
|
||||
int insert_pos = is_link ? 1 : 0;
|
||||
* For page background: insert the "Open Page in New Tab" item at
|
||||
* position 0 (top). */
|
||||
int insert_pos;
|
||||
if (is_link) {
|
||||
/* Find and remove WebKit's stock "Open Link in New Window" item
|
||||
* so we can replace it with our own below. */
|
||||
guint n = webkit_context_menu_get_n_items(context_menu);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
WebKitContextMenuItem *it =
|
||||
webkit_context_menu_get_item_at_position(context_menu, i);
|
||||
if (it == NULL) continue;
|
||||
if (webkit_context_menu_item_get_stock_action(it) ==
|
||||
WEBKIT_CONTEXT_MENU_ACTION_OPEN_LINK_IN_NEW_WINDOW) {
|
||||
webkit_context_menu_remove(context_menu, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
insert_pos = 1;
|
||||
} else {
|
||||
insert_pos = 0;
|
||||
}
|
||||
webkit_context_menu_insert(context_menu, item, insert_pos);
|
||||
|
||||
/* The action is owned by the menu item now. g_object_unref is safe
|
||||
* because the WebKitContextMenuItem holds its own ref. */
|
||||
g_object_unref(action);
|
||||
|
||||
/* For links: also add our own "Open Link in New Window" item that
|
||||
* opens a real top-level GtkWindow. Insert it right after the
|
||||
* "Open Link in New Tab" item we just added (position 2). */
|
||||
if (is_link) {
|
||||
GSimpleAction *win_action =
|
||||
g_simple_action_new("open-in-new-window", NULL);
|
||||
struct new_window_ctx *ctx = g_slice_new(struct new_window_ctx);
|
||||
ctx->url = g_strdup(url);
|
||||
ctx->related_view = webview; /* share WebProcess with parent */
|
||||
g_signal_connect(win_action, "activate",
|
||||
G_CALLBACK(on_context_open_in_new_window), ctx);
|
||||
|
||||
WebKitContextMenuItem *win_item =
|
||||
webkit_context_menu_item_new_from_gaction(
|
||||
G_ACTION(win_action), "Open Link in New Window", NULL);
|
||||
webkit_context_menu_insert(context_menu, win_item, 2);
|
||||
g_object_unref(win_action);
|
||||
}
|
||||
|
||||
return FALSE; /* Let WebKit show the menu. */
|
||||
}
|
||||
|
||||
@@ -1949,10 +2108,16 @@ static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
|
||||
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
if (g_window == NULL) return;
|
||||
/* Parent the dialog to the currently focused window (g_active_window)
|
||||
* rather than always the main window (g_window), so the file chooser
|
||||
* appears over the window the user invoked it from. g_active_window is
|
||||
* kept up to date by on_window_focus_in() whenever a window gains
|
||||
* focus; fall back to g_window if it hasn't been set yet. */
|
||||
GtkWindow *parent = g_active_window ? g_active_window : g_window;
|
||||
if (parent == NULL) return;
|
||||
|
||||
GtkWidget *dialog = gtk_file_chooser_dialog_new(
|
||||
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
|
||||
"Open File", parent, GTK_FILE_CHOOSER_ACTION_OPEN,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_Open", GTK_RESPONSE_ACCEPT, NULL);
|
||||
|
||||
@@ -2063,16 +2228,12 @@ static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Called when "Manage Bookmarks…" is clicked in the submenu. */
|
||||
/* Called when "Manage Bookmarks…" is clicked in the submenu.
|
||||
* Always opens in a new tab so the user's current page is preserved. */
|
||||
static void on_manage_bookmarks_clicked(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://bookmarks");
|
||||
} else {
|
||||
tab_manager_new_tab("sovereign://bookmarks");
|
||||
}
|
||||
tab_manager_new_tab("sovereign://bookmarks");
|
||||
}
|
||||
|
||||
/* Wrapper for g_free to match GClosureNotify signature (avoids
|
||||
@@ -2175,9 +2336,12 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
|
||||
const gchar *title = webkit_web_view_get_title(tab->webview);
|
||||
if (title == NULL) title = "";
|
||||
|
||||
/* Build the folder picker dialog. */
|
||||
/* Build the folder picker dialog. Parent it to the focused window
|
||||
* (g_active_window) so it appears over the window the user invoked
|
||||
* it from, falling back to the main window if unset. */
|
||||
GtkWindow *bm_parent = g_active_window ? g_active_window : g_window;
|
||||
GtkWidget *dialog = gtk_dialog_new_with_buttons(
|
||||
"Bookmark Page", g_window,
|
||||
"Bookmark Page", bm_parent,
|
||||
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_Add", GTK_RESPONSE_ACCEPT,
|
||||
@@ -2324,7 +2488,7 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
|
||||
g_signal_connect(item_lock, "activate",
|
||||
G_CALLBACK(app_menu_lock_session_proxy), NULL);
|
||||
g_signal_connect(item_logout, "activate",
|
||||
G_CALLBACK(app_menu_logout_proxy), NULL);
|
||||
G_CALLBACK(app_menu_logout_proxy), g_window);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
|
||||
@@ -2556,7 +2720,10 @@ static void bookmark_bar_add_folder(GtkWidget *container,
|
||||
const bookmark_t *bm = &node->bookmarks[i];
|
||||
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
|
||||
GtkWidget *btn = gtk_button_new_with_label(label);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NORMAL);
|
||||
gtk_style_context_add_class(gtk_widget_get_style_context(btn),
|
||||
"bookmark-bar-btn");
|
||||
gtk_widget_set_name(btn, "bookmark-bar-btn");
|
||||
gtk_widget_set_tooltip_text(btn, bm->url);
|
||||
char *url_copy = g_strdup(bm->url);
|
||||
g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy,
|
||||
@@ -2607,6 +2774,10 @@ static void bookmark_bar_add_folder(GtkWidget *container,
|
||||
}
|
||||
|
||||
GtkWidget *mb = gtk_menu_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(mb), GTK_RELIEF_NORMAL);
|
||||
gtk_style_context_add_class(gtk_widget_get_style_context(mb),
|
||||
"bookmark-bar-btn");
|
||||
gtk_widget_set_name(mb, "bookmark-bar-menu-btn");
|
||||
gtk_button_set_label(GTK_BUTTON(mb), child->name);
|
||||
gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu);
|
||||
gtk_widget_set_tooltip_text(mb, child->path);
|
||||
@@ -2803,10 +2974,12 @@ static tab_info_t *tab_create(const char *url) {
|
||||
g_object_set_data(G_OBJECT(tab->page), "tab-info", tab);
|
||||
|
||||
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_name(toolbar, "main-toolbar");
|
||||
gtk_widget_set_margin_top(toolbar, 4);
|
||||
gtk_widget_set_margin_bottom(toolbar, 4);
|
||||
gtk_widget_set_margin_start(toolbar, 4);
|
||||
gtk_widget_set_margin_end(toolbar, 4);
|
||||
tab->toolbar = toolbar;
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
|
||||
|
||||
tab->hamburger = build_hamburger_menu(tab);
|
||||
@@ -2926,6 +3099,17 @@ static tab_info_t *tab_create(const char *url) {
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
bookmark_bar_refresh(tab);
|
||||
|
||||
/* Apply the active window's menu-bar visibility so a tab opened in a
|
||||
* hidden-chrome window is also hidden. The tab strip itself is
|
||||
* per-notebook (gtk_notebook_set_show_tabs), already set by the
|
||||
* toggle; here we only sync the per-tab toolbar + bookmark bar. */
|
||||
{
|
||||
window_state_t *ws = get_active_window_state();
|
||||
gboolean vis = ws ? ws->toolbars_visible : TRUE;
|
||||
gtk_widget_set_visible(tab->toolbar, vis);
|
||||
gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
}
|
||||
|
||||
/* Ensure the webview expands vertically to fill the available space.
|
||||
* Without this, WebKitGTK may only allocate 1px of height on some
|
||||
* display servers, resulting in a blank page. */
|
||||
@@ -2976,7 +3160,7 @@ static tab_info_t *tab_create(const char *url) {
|
||||
* _M_get() const: Assertion 'this->_M_is_engaged()' failed.
|
||||
* So we skip the explicit load and let WebKit drive the navigation. The
|
||||
* tab's URL bar / title were already initialized from default_url above. */
|
||||
if (g_target_related_view == NULL) {
|
||||
if (!g_target_skip_load) {
|
||||
webkit_web_view_load_uri(tab->webview, default_url);
|
||||
}
|
||||
g_free(default_url);
|
||||
@@ -3208,14 +3392,9 @@ void tab_manager_set_avatar(const char *pubkey_hex) {
|
||||
static void on_avatar_clicked(GtkButton *btn, gpointer data) {
|
||||
(void)btn;
|
||||
(void)data;
|
||||
/* Open sovereign://profile in the active tab, or a new tab if
|
||||
* none exists. */
|
||||
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");
|
||||
}
|
||||
/* Always open sovereign://profile in a new tab so the user's current
|
||||
* page is preserved. */
|
||||
tab_manager_new_tab("sovereign://profile");
|
||||
}
|
||||
|
||||
/* ── New tab button ───────────────────────────────────────────────── */
|
||||
@@ -3279,26 +3458,69 @@ static void apply_app_theme(void) {
|
||||
"#avatar-btn image, #hamburger-btn image {"
|
||||
" padding: 0px; margin: 0px;"
|
||||
"}"
|
||||
/* ── Header chrome background: force white ─────────────────── */
|
||||
"notebook, notebook header, notebook > header,"
|
||||
"notebook > header > tabs, notebook > header > tabs > tab,"
|
||||
"#main-toolbar, #bookmark-bar {"
|
||||
" background: #ffffff;"
|
||||
" color: #000000;"
|
||||
"}"
|
||||
"#main-toolbar entry {"
|
||||
" background: #ffffff;"
|
||||
" color: #000000;"
|
||||
"}"
|
||||
/* ── Red accent for the URL entry focus ring ──────────────── */
|
||||
"entry:focus {"
|
||||
" border-color: #ff0000 !important;"
|
||||
" box-shadow: 0 0 0 1px #ff0000 !important;"
|
||||
" border-color: #ff0000;"
|
||||
" box-shadow: 0 0 0 1px #ff0000;"
|
||||
"}"
|
||||
/* ── Tab active underline: black bar (replaces blue) ────────
|
||||
* Adwaita renders the active-tab highlight as a box-shadow inset
|
||||
* on tab:checked. We kill that and draw a solid border-bottom
|
||||
* in the fg color. !important is needed to beat the theme. */
|
||||
"notebook tab:checked {"
|
||||
" box-shadow: none !important;"
|
||||
" outline: none !important;"
|
||||
" border-bottom: 3px solid %s !important;"
|
||||
" box-shadow: none;"
|
||||
" outline: none;"
|
||||
" border-bottom: 3px solid %s;"
|
||||
"}"
|
||||
/* Tab text: normal (inherit from theme), no red. */
|
||||
/* ── Bookmark bar buttons — compact padding ───────────────── */
|
||||
"#bookmark-bar button {"
|
||||
/* ── Bookmark bar buttons — explicit WEB.md interaction model ─
|
||||
* Rest: white background + black 1px border + black text.
|
||||
* Hover: border turns red (background stays white).
|
||||
* Active/pressed: background turns red + white text.
|
||||
* Use !important at USER priority to beat Adwaita states. */
|
||||
"#bookmark-bar-btn, #bookmark-bar-menu-btn,"
|
||||
"#bookmark-bar-menu-btn button,"
|
||||
"#bookmark-bar .bookmark-bar-btn,"
|
||||
"#bookmark-bar .bookmark-bar-btn:backdrop,"
|
||||
"#bookmark-bar .bookmark-bar-btn button,"
|
||||
"#bookmark-bar .bookmark-bar-btn button:backdrop {"
|
||||
" padding: 2px 8px;"
|
||||
" background: #ffffff;"
|
||||
" color: #000000;"
|
||||
" border: 1px solid #000000;"
|
||||
" border-radius: 4px;"
|
||||
" box-shadow: none;"
|
||||
"}"
|
||||
"#bookmark-bar-btn:hover, #bookmark-bar-menu-btn:hover,"
|
||||
"#bookmark-bar-menu-btn button:hover,"
|
||||
"#bookmark-bar .bookmark-bar-btn:hover,"
|
||||
"#bookmark-bar .bookmark-bar-btn button:hover {"
|
||||
" border-color: #ff0000;"
|
||||
"}"
|
||||
"#bookmark-bar-btn:active, #bookmark-bar-btn:checked,"
|
||||
"#bookmark-bar-menu-btn:active, #bookmark-bar-menu-btn:checked,"
|
||||
"#bookmark-bar-menu-btn button:active,"
|
||||
"#bookmark-bar-menu-btn button:checked,"
|
||||
"#bookmark-bar .bookmark-bar-btn:active,"
|
||||
"#bookmark-bar .bookmark-bar-btn:checked,"
|
||||
"#bookmark-bar .bookmark-bar-btn button:active,"
|
||||
"#bookmark-bar .bookmark-bar-btn button:checked {"
|
||||
" background: #ff0000;"
|
||||
" color: #ffffff;"
|
||||
" border-color: #ff0000;"
|
||||
"}",
|
||||
fg /* tab:checked border-bottom color */
|
||||
fg /* tab:checked border-bottom color */
|
||||
);
|
||||
|
||||
if (g_app_theme_provider == NULL) {
|
||||
@@ -3366,6 +3588,14 @@ static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main)
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────────── */
|
||||
|
||||
void tab_manager_set_context(WebKitWebContext *ctx) {
|
||||
/* The caller must have closed all tabs and sidebar webviews that
|
||||
* reference the old context before calling this, otherwise those
|
||||
* webviews will hold dangling context references. */
|
||||
g_ctx = ctx;
|
||||
g_print("[tab-manager] Context updated (%p)\n", (void *)g_ctx);
|
||||
}
|
||||
|
||||
void tab_manager_init(GtkContainer *parent,
|
||||
WebKitWebContext *ctx,
|
||||
GtkWindow *window) {
|
||||
@@ -3426,6 +3656,7 @@ void tab_manager_init(GtkContainer *parent,
|
||||
g_main_window.notebook = g_notebook;
|
||||
g_main_window.sidebar_webview = NULL;
|
||||
g_main_window.sidebar_visible = FALSE;
|
||||
g_main_window.toolbars_visible = TRUE;
|
||||
|
||||
/* The main window/notebook are the default active window/notebook
|
||||
* for MCP get_active_webview() and keyboard shortcuts (zoom, etc.).
|
||||
@@ -3483,8 +3714,11 @@ int tab_manager_new_tab(const char *url) {
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting (see comment above). */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
/* Add to the effective notebook (active window's notebook, or the
|
||||
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
|
||||
@@ -3511,8 +3745,23 @@ int tab_manager_new_tab(const char *url) {
|
||||
gtk_widget_grab_focus(tab->url_entry);
|
||||
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
|
||||
|
||||
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
|
||||
return page_num;
|
||||
g_print("[tabs] Created tab %d (page %d), total: %d\n",
|
||||
index, page_num, g_tab_count);
|
||||
|
||||
/* Return the g_tabs array index (NOT the notebook page_num).
|
||||
*
|
||||
* The notebook page number only matches the g_tabs index for the
|
||||
* main window; auxiliary windows have their own page numbering, and
|
||||
* even in the main window the two can diverge after tabs are closed
|
||||
* and reopened. The only caller that uses the return value
|
||||
* (on_create_webview, for the WebKit "create" signal) passes it to
|
||||
* tab_manager_get(), which expects a g_tabs index. Returning
|
||||
* page_num there caused tab_manager_get() to resolve the WRONG tab
|
||||
* — an already-existing webview with no pending WindowFeatures —
|
||||
* which WebKit then tried to apply WindowFeatures to, triggering:
|
||||
* std::optional<WindowFeatures>::_M_get(): Assertion
|
||||
* 'this->_M_is_engaged()' failed. */
|
||||
return index;
|
||||
}
|
||||
|
||||
void tab_manager_close_tab(int index) {
|
||||
@@ -3557,15 +3806,26 @@ void tab_manager_close_tab(int index) {
|
||||
}
|
||||
}
|
||||
|
||||
/* If the main window has no tabs left, quit the app. */
|
||||
/* If the main window has no tabs left, quit the app — unless a
|
||||
* caller has suppressed the quit (e.g. identity_teardown_web_state
|
||||
* closes all tabs to wipe the previous user's live webviews but
|
||||
* wants the browser to keep running for the next user). */
|
||||
if (g_tab_count == 0) {
|
||||
g_print("[tabs] Last tab closed, exiting.\n");
|
||||
if (g_window) {
|
||||
gtk_window_close(g_window);
|
||||
if (g_suppress_quit_on_last_tab) {
|
||||
g_print("[tabs] Last tab closed, but quit suppressed (identity switch in progress).\n");
|
||||
} else {
|
||||
g_print("[tabs] Last tab closed, exiting.\n");
|
||||
if (g_window) {
|
||||
gtk_window_close(g_window);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress) {
|
||||
g_suppress_quit_on_last_tab = suppress;
|
||||
}
|
||||
|
||||
void tab_manager_close_active(void) {
|
||||
/* Resolve the active tab by widget pointer (works across windows),
|
||||
* then look up its g_tabs index. Using tab_manager_get_active_index()
|
||||
@@ -3689,6 +3949,53 @@ void tab_manager_close_all(void) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Destroy the sidebar webview in a single window_state_t.
|
||||
* The sidebar container widget itself is kept; the webview will be
|
||||
* recreated lazily on the next tab_manager_toggle_sidebar() call. */
|
||||
static void window_state_destroy_sidebar(window_state_t *ws) {
|
||||
if (ws == NULL) return;
|
||||
if (ws->sidebar_webview == NULL) {
|
||||
/* Still mark it hidden so the next toggle re-creates it. */
|
||||
ws->sidebar_visible = FALSE;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Destroy the webview widget. gtk_widget_destroy() drops the
|
||||
* container's reference and unrefs the WebKitWebView. */
|
||||
GtkWidget *wv = GTK_WIDGET(ws->sidebar_webview);
|
||||
/* Detach from the sidebar container so the container is reusable. */
|
||||
GtkWidget *parent = gtk_widget_get_parent(wv);
|
||||
if (parent != NULL) {
|
||||
gtk_container_remove(GTK_CONTAINER(parent), wv);
|
||||
} else {
|
||||
gtk_widget_destroy(wv);
|
||||
}
|
||||
|
||||
ws->sidebar_webview = NULL;
|
||||
ws->sidebar_visible = FALSE;
|
||||
|
||||
/* Hide the sidebar container so it doesn't show empty space. */
|
||||
if (ws->sidebar_container != NULL) {
|
||||
gtk_widget_hide(ws->sidebar_container);
|
||||
}
|
||||
if (ws->paned != NULL) {
|
||||
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
|
||||
}
|
||||
}
|
||||
|
||||
void tab_manager_destroy_sidebar_webviews(void) {
|
||||
window_state_destroy_sidebar(&g_main_window);
|
||||
if (g_aux_windows != NULL) {
|
||||
for (guint i = 0; i < g_aux_windows->len; i++) {
|
||||
window_state_t *ws = &g_array_index(g_aux_windows,
|
||||
window_state_t, i);
|
||||
window_state_destroy_sidebar(ws);
|
||||
}
|
||||
}
|
||||
/* If the active window's sidebar was showing, the active_ws pointer
|
||||
* is still valid (we only nulled the webview, not the struct). */
|
||||
}
|
||||
|
||||
void tab_manager_duplicate(int index) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
@@ -3873,6 +4180,28 @@ void tab_manager_toggle_sidebar(void) {
|
||||
}
|
||||
}
|
||||
|
||||
void tab_manager_toggle_toolbars(void) {
|
||||
window_state_t *ws = get_active_window_state();
|
||||
if (ws == NULL || ws->notebook == NULL) return;
|
||||
|
||||
ws->toolbars_visible = !ws->toolbars_visible;
|
||||
gboolean vis = ws->toolbars_visible;
|
||||
|
||||
/* Tab strip (GtkNotebook header) — per-notebook. */
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), vis);
|
||||
|
||||
/* Per-tab toolbar + bookmark bar, but only for tabs that belong to
|
||||
* this window's notebook (aux windows have their own notebook). */
|
||||
for (int i = 0; i < g_tab_count; i++) {
|
||||
tab_info_t *tab = g_tabs[i];
|
||||
if (tab == NULL || tab->page == NULL) continue;
|
||||
GtkWidget *nb = tab_find_notebook(tab->page);
|
||||
if (nb != ws->notebook) continue;
|
||||
if (tab->toolbar) gtk_widget_set_visible(tab->toolbar, vis);
|
||||
if (tab->bookmark_bar) gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
}
|
||||
}
|
||||
|
||||
WebKitWebView *tab_manager_get_main_webview(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab == NULL) return NULL;
|
||||
|
||||
@@ -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 *toolbar; /* #main-toolbar (hamburger + nav + URL) */
|
||||
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
|
||||
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
|
||||
char current_url[TAB_URL_MAX];
|
||||
@@ -47,6 +48,18 @@ void tab_manager_init(GtkContainer *parent,
|
||||
WebKitWebContext *ctx,
|
||||
GtkWindow *window);
|
||||
|
||||
/*
|
||||
* Update the WebKitWebContext that tab_manager_new_tab() and
|
||||
* sidebar_create_webview() use to create new webviews. Used during an
|
||||
* identity switch: the old context is torn down (after all tabs are
|
||||
* closed) and a fresh per-user context is built; this function updates
|
||||
* the stored pointer so subsequent new tabs use the new context.
|
||||
*
|
||||
* The caller MUST have closed all existing tabs and sidebar webviews
|
||||
* (which reference the old context) before calling this.
|
||||
*/
|
||||
void tab_manager_set_context(WebKitWebContext *ctx);
|
||||
|
||||
/*
|
||||
* Create a new tab and load the given URL (or the default new-tab URL
|
||||
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
|
||||
@@ -142,6 +155,25 @@ void tab_manager_close_to_right(int index);
|
||||
*/
|
||||
void tab_manager_close_all(void);
|
||||
|
||||
/*
|
||||
* Destroy the agent chat sidebar webview in every open window (main + aux).
|
||||
* The sidebar container itself is kept (it will be re-populated lazily on the
|
||||
* next toggle). Used during logout / identity switch so the sidebar — which
|
||||
* holds a long-lived WebKitWebView with the previous user's session — does
|
||||
* not leak the old identity's sovereign://agents/chat state across users.
|
||||
*/
|
||||
void tab_manager_destroy_sidebar_webviews(void);
|
||||
|
||||
/*
|
||||
* Temporarily suppress the "last tab closed → quit the app" behavior.
|
||||
* Set TRUE before tab_manager_close_all() during an identity switch so
|
||||
* the browser does not exit when the previous user's tabs are closed;
|
||||
* the caller opens a fresh tab for the new user afterwards. Always
|
||||
* reset to FALSE when done so normal quit-on-last-tab-close behavior
|
||||
* is restored.
|
||||
*/
|
||||
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
|
||||
|
||||
/*
|
||||
* Duplicate the tab at the given index (open a new tab with the same URL).
|
||||
*/
|
||||
@@ -197,6 +229,15 @@ void tab_manager_zoom_reset(void);
|
||||
*/
|
||||
void tab_manager_toggle_sidebar(void);
|
||||
|
||||
/*
|
||||
* Toggle visibility of all "menu bars" in the active window: the tab strip
|
||||
* (GtkNotebook header), each tab's #main-toolbar, and each tab's bookmark
|
||||
* bar. Per-window — only the focused window is affected, mirroring
|
||||
* tab_manager_toggle_sidebar(). New tabs opened in a hidden-chrome window
|
||||
* inherit the hidden state.
|
||||
*/
|
||||
void tab_manager_toggle_toolbars(void);
|
||||
|
||||
/*
|
||||
* Returns the main webview (the web page) of the active tab, never the
|
||||
* sidebar webview. This is used by agent tools so they always operate
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.54"
|
||||
#define SB_VERSION "v0.0.63"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 54
|
||||
#define SB_VERSION_PATCH 63
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* web_context.c — per-user WebKitWebContext construction/teardown
|
||||
*
|
||||
* See web_context.h for the rationale. Phase A of
|
||||
* plans/webkit-data-isolation.md.
|
||||
*/
|
||||
|
||||
#include "web_context.h"
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* The current per-user context. NULL before the first build and after
|
||||
* teardown. Replaces webkit_web_context_get_default() for code that
|
||||
* wants the per-user context. */
|
||||
static WebKitWebContext *g_ctx = NULL;
|
||||
|
||||
/* ── Download handling ─────────────────────────────────────────────── *
|
||||
* WebKitGTK fires the "download-started" signal on the WebKitWebContext
|
||||
* whenever the user triggers a download — most notably the "Save Image"
|
||||
* context-menu item on an <img>. In the webkit2gtk-4.1 API the download
|
||||
* does NOT auto-prompt for a destination: the application must handle the
|
||||
* download's "decide-destination" signal (which supplies a suggested
|
||||
* filename), call webkit_download_set_destination(), and return TRUE.
|
||||
* If nobody handles "decide-destination", WebKit writes the file to
|
||||
* G_USER_DIRECTORY_DOWNLOAD using the suggested name — but on many
|
||||
* minimal/containerized setups that directory resolves to a path the
|
||||
* user never sees, so "Save Image" appears to do nothing.
|
||||
*
|
||||
* Our flow:
|
||||
* download-started (context) → take a ref, wire finished/failed
|
||||
* decide-destination (download) → show a GtkFileChooserDialog pre-filled
|
||||
* with the suggested filename, set the
|
||||
* chosen destination, return TRUE
|
||||
* finished / failed → unref the download
|
||||
*
|
||||
* Since 2.40, returning TRUE from "decide-destination" without
|
||||
* immediately calling webkit_download_set_destination() is allowed: the
|
||||
* download pauses until the destination is set or the download is
|
||||
* cancelled. We use that to run a modal file chooser inside the handler.
|
||||
*/
|
||||
|
||||
/* Find a reasonable parent GtkWindow for the file chooser. We prefer the
|
||||
* currently-active (focused) top-level window; if none is focused we fall
|
||||
* back to the first visible top-level. web_context.c deliberately does
|
||||
* not depend on tab_manager.c's g_active_window, so this stays self-
|
||||
* contained. Returns NULL if no usable window is found. */
|
||||
static GtkWindow *pick_dialog_parent(void) {
|
||||
GList *toplevels = gtk_window_list_toplevels();
|
||||
GtkWindow *active = NULL;
|
||||
GtkWindow *first_visible = NULL;
|
||||
|
||||
for (GList *l = toplevels; l != NULL; l = l->next) {
|
||||
GtkWindow *w = GTK_WINDOW(l->data);
|
||||
if (!gtk_widget_get_visible(GTK_WIDGET(w))) continue;
|
||||
if (first_visible == NULL) first_visible = w;
|
||||
if (gtk_window_is_active(w)) { active = w; break; }
|
||||
}
|
||||
|
||||
GtkWindow *result = active ? active : first_visible;
|
||||
if (result != NULL) g_object_ref_sink(G_OBJECT(result));
|
||||
g_list_free(toplevels);
|
||||
return result; /* caller unrefs */
|
||||
}
|
||||
|
||||
/* Reduce a possibly-full suggested path to its final path segment so the
|
||||
* save dialog never starts in an unexpected directory. Returns a newly
|
||||
* allocated string. */
|
||||
static char *basename_of(const char *name) {
|
||||
if (name == NULL || name[0] == '\0') return NULL;
|
||||
const char *slash = strrchr(name, '/');
|
||||
return g_strdup(slash ? slash + 1 : name);
|
||||
}
|
||||
|
||||
static void on_download_finished(WebKitDownload *dl, gpointer user) {
|
||||
(void)user;
|
||||
const char *dest = webkit_download_get_destination(dl);
|
||||
g_print("[download] Finished: %s\n", dest ? dest : "(no destination)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
static void on_download_failed(WebKitDownload *dl, GError *err, gpointer user) {
|
||||
(void)user;
|
||||
g_printerr("[download] Failed: %s\n", err ? err->message : "(unknown)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
/* "decide-destination" handler. WebKit passes the suggested filename
|
||||
* (derived from the response Content-Disposition / URL). We show a save
|
||||
* dialog, set the destination, and return TRUE. Returning TRUE without
|
||||
* setting a destination is explicitly supported since 2.40 and pauses
|
||||
* the download until we set one (or cancel). */
|
||||
|
||||
/* Remember the directory the user last saved a download to, so the next
|
||||
* save dialog opens in the same folder instead of the default. Owned by
|
||||
* this module (g_free'd on teardown). NULL until the first successful
|
||||
* save. */
|
||||
static char *g_last_save_dir = NULL;
|
||||
|
||||
static gboolean on_download_decide_destination(WebKitDownload *dl,
|
||||
const char *suggested,
|
||||
gpointer user) {
|
||||
(void)user;
|
||||
|
||||
char *default_name = basename_of(suggested);
|
||||
if (default_name == NULL || default_name[0] == '\0') {
|
||||
g_free(default_name);
|
||||
default_name = g_strdup("download");
|
||||
}
|
||||
|
||||
GtkWindow *parent = pick_dialog_parent();
|
||||
|
||||
GtkWidget *dialog = gtk_file_chooser_dialog_new(
|
||||
"Save File", parent, GTK_FILE_CHOOSER_ACTION_SAVE,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_Save", GTK_RESPONSE_ACCEPT,
|
||||
NULL);
|
||||
if (parent) g_object_unref(parent);
|
||||
|
||||
GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog);
|
||||
|
||||
/* If the user saved a download before, open the dialog in that same
|
||||
* directory. Otherwise GTK falls back to the current working
|
||||
* directory / last-used folder for the application. */
|
||||
if (g_last_save_dir != NULL && g_last_save_dir[0] != '\0') {
|
||||
gtk_file_chooser_set_current_folder(chooser, g_last_save_dir);
|
||||
}
|
||||
|
||||
gtk_file_chooser_set_current_name(chooser, default_name);
|
||||
gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
|
||||
|
||||
gint resp = gtk_dialog_run(GTK_DIALOG(dialog));
|
||||
char *path = (resp == GTK_RESPONSE_ACCEPT)
|
||||
? gtk_file_chooser_get_filename(chooser)
|
||||
: NULL;
|
||||
gtk_widget_destroy(dialog);
|
||||
g_free(default_name);
|
||||
|
||||
if (path == NULL) {
|
||||
/* User cancelled — abort the download. */
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE; /* we handled it (by cancelling) */
|
||||
}
|
||||
|
||||
/* Remember the directory the user chose for next time. */
|
||||
char *dir = g_path_get_dirname(path);
|
||||
if (dir != NULL && dir[0] != '\0' &&
|
||||
(g_last_save_dir == NULL ||
|
||||
g_strcmp0(dir, g_last_save_dir) != 0)) {
|
||||
g_free(g_last_save_dir);
|
||||
g_last_save_dir = g_strdup(dir);
|
||||
}
|
||||
g_free(dir);
|
||||
|
||||
char *uri = g_filename_to_uri(path, NULL, NULL);
|
||||
g_free(path);
|
||||
if (uri == NULL) {
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
webkit_download_set_destination(dl, uri);
|
||||
g_free(uri);
|
||||
return TRUE; /* stop other handlers; destination is now set */
|
||||
}
|
||||
|
||||
static void on_download_started(WebKitWebContext *ctx,
|
||||
WebKitDownload *dl,
|
||||
gpointer user_data) {
|
||||
(void)ctx;
|
||||
(void)user_data;
|
||||
|
||||
/* Hold a ref for the lifetime of the download so our finished/failed
|
||||
* handlers can safely access it; we unref there. */
|
||||
g_object_ref(dl);
|
||||
|
||||
g_signal_connect(dl, "decide-destination",
|
||||
G_CALLBACK(on_download_decide_destination), NULL);
|
||||
g_signal_connect(dl, "finished",
|
||||
G_CALLBACK(on_download_finished), NULL);
|
||||
g_signal_connect(dl, "failed",
|
||||
G_CALLBACK(on_download_failed), NULL);
|
||||
}
|
||||
|
||||
/* ── Security / TLS / favicon configuration ────────────────────────── *
|
||||
* Applied to every context we build (per-user and ephemeral). Mirrors
|
||||
* the configuration that main.c used to apply to the default context.
|
||||
*/
|
||||
static void configure_context(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
|
||||
/* Security strip: register sovereign://, tor://, file:// as secure
|
||||
* (no CORS enforcement), and file:// as local. Same as the original
|
||||
* main.c configuration.
|
||||
*
|
||||
* Also register ws (insecure WebSocket) as secure. Without this,
|
||||
* WebKitGTK blocks ws:// connections opened from https:// pages as
|
||||
* mixed active content (a secure page may not connect to an
|
||||
* insecure origin). This browser is intentionally open and should
|
||||
* be able to connect to anywhere, including local plaintext
|
||||
* WebSocket services, so we treat ws:// as a secure scheme — the
|
||||
* same status wss:// already has by default. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "ws");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
WebKitWebsiteDataManager *dm =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
webkit_website_data_manager_set_tls_errors_policy(
|
||||
dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
|
||||
|
||||
/* Enable the favicon database so notify::favicon fires. Use the
|
||||
* data manager's base data directory (per-user) so favicons are
|
||||
* isolated too. Passing NULL would use a shared default location. */
|
||||
char fav_dir[600];
|
||||
WebKitWebsiteDataManager *data_mgr =
|
||||
webkit_web_context_get_website_data_manager(ctx);
|
||||
const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr);
|
||||
if (base != NULL) {
|
||||
g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base);
|
||||
} else {
|
||||
/* Ephemeral data manager has no base dir; use a temp favicons
|
||||
* dir so the favicon DB still works in no-login mode. */
|
||||
g_snprintf(fav_dir, sizeof(fav_dir),
|
||||
"%s/sovereign_browser-favicons-ephemeral",
|
||||
g_get_tmp_dir());
|
||||
}
|
||||
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
|
||||
|
||||
/* Handle downloads — most importantly the "Save Image" context-menu
|
||||
* action. Without this handler WebKitGTK cancels the download (and
|
||||
* the user sees nothing happen). See on_download_started() above. */
|
||||
g_signal_connect(ctx, "download-started",
|
||||
G_CALLBACK(on_download_started), NULL);
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
|
||||
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
g_printerr("[web-context] No pubkey — cannot build per-user context\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Ensure the per-user webkit data directory exists. */
|
||||
if (profile_ensure_webkit_dir(pubkey_hex) != 0) {
|
||||
g_printerr("[web-context] Failed to create webkit dir for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char webkit_dir[600];
|
||||
profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir));
|
||||
if (webkit_dir[0] == '\0') {
|
||||
g_printerr("[web-context] Failed to get webkit dir path for %s\n",
|
||||
pubkey_hex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build the per-user data manager. base-data-directory covers
|
||||
* local-storage, indexed-db, websql, service-worker-registrations,
|
||||
* offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory
|
||||
* covers disk-cache. WebKit derives subdirs under these. */
|
||||
char data_dir[600];
|
||||
char cache_dir[600];
|
||||
g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir);
|
||||
g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir);
|
||||
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new(
|
||||
"base-data-directory", data_dir,
|
||||
"base-cache-directory", cache_dir,
|
||||
NULL);
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build a fresh context bound to this data manager. */
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The context owns the data manager now; drop our ref. */
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n",
|
||||
pubkey_hex, data_dir, cache_dir);
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_ephemeral(void) {
|
||||
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral();
|
||||
if (dm == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
|
||||
if (g_ctx == NULL) {
|
||||
g_printerr("[web-context] Failed to create ephemeral WebContext\n");
|
||||
g_object_unref(dm);
|
||||
return NULL;
|
||||
}
|
||||
g_object_unref(dm);
|
||||
|
||||
configure_context(g_ctx);
|
||||
|
||||
g_print("[web-context] Built ephemeral context (no on-disk persistence)\n");
|
||||
return g_ctx;
|
||||
}
|
||||
|
||||
void web_context_teardown(void) {
|
||||
if (g_ctx == NULL) return;
|
||||
|
||||
/* Unref the context. The context releases its reference to the data
|
||||
* manager. Any remaining webviews referencing this context would
|
||||
* keep it alive — the caller must have closed them first. */
|
||||
WebKitWebContext *old = g_ctx;
|
||||
g_ctx = NULL;
|
||||
g_object_unref(old);
|
||||
|
||||
g_print("[web-context] Torn down previous context\n");
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_get(void) {
|
||||
return g_ctx;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
@@ -33,20 +33,18 @@ static int passes = 0;
|
||||
else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */
|
||||
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.h. */
|
||||
#define LABEL "sovereign-browser/bookmarks-folder-id-v1"
|
||||
|
||||
/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */
|
||||
/* 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) {
|
||||
unsigned char hmac_key[32];
|
||||
if (nostr_hmac_sha256(privkey, 32,
|
||||
(const unsigned char *)LABEL, strlen(LABEL),
|
||||
hmac_key) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
if (path == NULL) path = "";
|
||||
char data[512];
|
||||
snprintf(data, sizeof(data), "%s:%s", LABEL, path);
|
||||
unsigned char mac[32];
|
||||
if (nostr_hmac_sha256(hmac_key, 32,
|
||||
(const unsigned char *)path, strlen(path),
|
||||
if (nostr_hmac_sha256(privkey, 32,
|
||||
(const unsigned char *)data, strlen(data),
|
||||
mac) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -60,6 +60,28 @@
|
||||
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;
|
||||
|
||||
@@ -217,6 +217,24 @@ function renderBookmark(bm, folder, 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 = '#';
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@
|
||||
<div class="field">
|
||||
<div><div class="setting-name">FIPS Mesh Network</div>
|
||||
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
|
||||
<div><a class="btn" href="sovereign://fips">Open FIPS Page</a></div>
|
||||
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
|
||||
</div>
|
||||
|
||||
<h2>Security</h2>
|
||||
|
||||
@@ -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