v0.0.2 - Initial Rust port: full feature parity with C version including Tor .onion SOCKS5 routing, hamburger menu, keyboard shortcuts, settings page, NIP-07 injection, agent tools, perf probe, and versioning scheme

This commit is contained in:
Laan Tungir
2026-08-17 14:42:10 -04:00
commit 873d76fe4e
90 changed files with 22880 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target/
*.log
*.tar.gz
Generated
+4110
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
[workspace]
resolver = "2"
members = [
".",
]
[package]
name = "sovereign-browser"
version = "0.0.2"
edition = "2021"
license = "MIT"
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
[dependencies]
# ── Nostr Core Library (local path dependency) ──────────────────────
nostr-core = { path = "/home/user/lt/nostr_core_lib_rust/core" }
nostr-relay = { path = "/home/user/lt/nostr_core_lib_rust/relay" }
nostr-nips = { path = "/home/user/lt/nostr_core_lib_rust/nips" }
nostr-signer = { path = "/home/user/lt/nostr_core_lib_rust/signer" }
nostr-services = { path = "/home/user/lt/nostr_core_lib_rust/services" }
# ── GTK / WebKit ────────────────────────────────────────────────────
gtk = { version = "0.18", features = ["v3_24"] }
gdk = "0.18"
gdk-pixbuf = "0.18"
webkit2gtk = { version = "2.0", features = ["v2_40"] }
glib = "0.18"
gio = { version = "0.18", features = ["v2_76"] }
cairo-rs = "0.18"
pango = "0.18"
# ── Async Runtime ───────────────────────────────────────────────────
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
# ── Serialization ───────────────────────────────────────────────────
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ── Database ────────────────────────────────────────────────────────
rusqlite = { version = "0.32", features = ["bundled"] }
# ── Networking ──────────────────────────────────────────────────────
reqwest = { version = "0.12", features = ["json", "native-tls", "socks", "blocking"] }
url = "2"
# ── Crypto ──────────────────────────────────────────────────────────
hex = "0.4"
base64 = "0.22"
rand = "0.8"
sha2 = "0.10"
hmac = "0.12"
secp256k1 = { version = "0.29", features = ["rand-std", "hashes", "global-context"] }
zeroize = { version = "1", features = ["zeroize_derive"] }
# ── Encoding ────────────────────────────────────────────────────────
bech32 = "0.11"
urlencoding = "2"
# ── Utilities ───────────────────────────────────────────────────────
chrono = "0.4"
uuid = { version = "1", features = ["v4"] }
log = "0.4"
env_logger = "0.11"
thiserror = "2"
anyhow = "1"
once_cell = "1"
lazy_static = "1"
regex = "1"
qrcode = "0.14"
image = "0.25"
+1
View File
@@ -0,0 +1 @@
0.0.2
+407
View File
@@ -0,0 +1,407 @@
#!/bin/bash
set -e
# increment_and_push.sh - Version increment and git automation script
# for sovereign_browser_rust
#
# Usage:
# ./increment_and_push.sh "meaningful git comment"
# ./increment_and_push.sh -p "meaningful git comment" # patch bump (default)
# ./increment_and_push.sh -m "meaningful git comment" # minor bump
# ./increment_and_push.sh -M "meaningful git comment" # major bump
# ./increment_and_push.sh -r -p "meaningful git comment" # release: build, tag, push, gitea release + assets
# ./increment_and_push.sh --set-version vX.Y.Z "comment" # explicit version
#
# Mirrors the sovereign_browser (C) workflow: -p/-m/-M/-r flags, version read
# from git tags, updates VERSION, src/version.rs, and Cargo.toml, and (in
# release mode) builds the binary, creates a Gitea release, and uploads the
# binary + source tarball.
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# --- Cleanup trap -------------------------------------------------------
CLEANUP_FILES=()
cleanup_on_exit() {
for f in "${CLEANUP_FILES[@]}"; do
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
done
}
trap cleanup_on_exit EXIT INT TERM
register_cleanup() {
[[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true
}
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
GITEA_API_URL="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser_rust"
BIN_NAME="sovereign-browser"
RELEASE_BIN_PATH="target/release/${BIN_NAME}"
# --- Args ---------------------------------------------------------------
COMMIT_MESSAGE=""
RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
TARGET_VERSION=""
show_usage() {
echo "sovereign_browser_rust Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create a Gitea release with built binary + tarball"
echo " --set-version vX.Y.Z Set an explicit version instead of incrementing"
echo " -h, --help Show this help message"
echo ""
echo "EXAMPLES:"
echo " $0 \"Add Tor .onion routing\""
echo " $0 -m \"Settings page parity\""
echo " $0 -r -p \"Release: Tor SOCKS proxy support\""
echo " $0 --set-version v0.1.0 \"First stable release\""
}
while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
shift
;;
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
shift
;;
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
shift
;;
-M|--major)
VERSION_INCREMENT_TYPE="major"
shift
;;
--set-version)
if [[ -z "$2" ]]; then
print_error "--set-version requires a version argument (vX.Y.Z)"
exit 1
fi
TARGET_VERSION="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
*)
if [[ -z "$COMMIT_MESSAGE" ]]; then
COMMIT_MESSAGE="$1"
fi
shift
;;
esac
done
if [[ -z "$COMMIT_MESSAGE" ]]; then
print_error "Commit message is required"
show_usage
exit 1
fi
# --- Checks -------------------------------------------------------------
# Check if we're in the correct directory
CURRENT_DIR=$(basename "$(pwd)")
if [ "$CURRENT_DIR" != "sovereign_browser_rust" ]; then
print_error "Script must be run from the sovereign_browser_rust directory"
echo ""
echo "Current directory: $CURRENT_DIR"
echo "Expected directory: sovereign_browser_rust"
exit 1
fi
# Check if git repository exists
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please initialize git first."
exit 1
fi
# Check that the version source files exist
if [ ! -f "src/version.rs" ]; then
print_error "src/version.rs not found"
exit 1
fi
if [ ! -f "Cargo.toml" ]; then
print_error "Cargo.toml not found"
exit 1
fi
# --- Version logic ------------------------------------------------------
update_version_files() {
local new_version="$1" # includes leading 'v'
local major="$2"
local minor="$3"
local patch="$4"
# VERSION file: plain X.Y.Z (no leading 'v')
echo "$major.$minor.$patch" > VERSION
print_success "Updated VERSION file -> $major.$minor.$patch"
# src/version.rs: Rust constants
sed -i "s/pub const VERSION: &str = .*/pub const VERSION: \&str = \"v$major.$minor.$patch\";/" src/version.rs
sed -i "s/pub const VERSION_MAJOR: u32 = .*/pub const VERSION_MAJOR: u32 = $major;/" src/version.rs
sed -i "s/pub const VERSION_MINOR: u32 = .*/pub const VERSION_MINOR: u32 = $minor;/" src/version.rs
sed -i "s/pub const VERSION_PATCH: u32 = .*/pub const VERSION_PATCH: u32 = $patch;/" src/version.rs
print_success "Updated src/version.rs -> v$major.$minor.$patch"
# Cargo.toml: package version (no leading 'v')
sed -i "s/^version = \".*\"/version = \"$major.$minor.$patch\"/" Cargo.toml
print_success "Updated Cargo.toml -> $major.$minor.$patch"
}
increment_version() {
local increment_type="$1"
# Read the latest version from git tags, falling back to the VERSION file.
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 2>/dev/null || echo "")
if [[ -z "$LATEST_TAG" ]]; then
if [ -f "VERSION" ]; then
local file_ver
file_ver=$(tr -d '[:space:]' < VERSION)
file_ver="${file_ver#v}"
LATEST_TAG="v$file_ver"
else
LATEST_TAG="v0.0.0"
fi
print_warning "No version tags found, starting from $LATEST_TAG"
fi
VERSION=${LATEST_TAG#v}
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
print_error "Invalid version format in tag: $LATEST_TAG"
exit 1
fi
if [[ "$increment_type" == "major" ]]; then
NEW_VERSION="v$((MAJOR + 1)).0.0"
elif [[ "$increment_type" == "minor" ]]; then
NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0"
else
NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
local new_no_v=${NEW_VERSION#v}
local new_major=${new_no_v%%.*}
local rest=${new_no_v#*.}
local new_minor=${rest%%.*}
local new_patch=${rest##*.}
update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch"
export NEW_VERSION
}
set_explicit_version() {
if [[ ! "$TARGET_VERSION" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
print_error "Invalid target version format: $TARGET_VERSION (expected vX.Y.Z)"
exit 1
fi
NEW_VERSION="$TARGET_VERSION"
local new_no_v=${NEW_VERSION#v}
local new_major=${new_no_v%%.*}
local rest=${new_no_v#*.}
local new_minor=${rest%%.*}
local new_patch=${rest##*.}
update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch"
export NEW_VERSION
}
# --- Git operations -----------------------------------------------------
git_commit_and_push() {
git add .
if ! git diff --staged --quiet; then
git commit -m "$NEW_VERSION - $COMMIT_MESSAGE"
print_success "Changes committed"
else
print_warning "No changes to commit"
fi
# Create (or recreate) the tag.
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
print_warning "Replaced existing tag: $NEW_VERSION"
fi
CURRENT_BRANCH=$(git branch --show-current)
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
}
# --- Release mode: build + Gitea release --------------------------------
build_release_binary() {
print_status "Building release binary..."
cargo build --release > /dev/null 2>&1 || return 1
if [[ ! -x "$RELEASE_BIN_PATH" ]]; then
print_error "Binary not found or not executable after build: $RELEASE_BIN_PATH"
return 1
fi
print_success "Release binary built: $RELEASE_BIN_PATH"
return 0
}
create_source_tarball() {
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
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.
set +e
tar -czf "$tmp_tarball" \
--exclude='target' \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
. > /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() {
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping release creation."
return 0
fi
local token
token=$(tr -d '\n\r' < "$HOME/.gitea_token")
local response
response=$(curl -s -X POST "$GITEA_API_URL/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
if echo "$response" | grep -q '"id"'; then
echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2
else
print_warning "Gitea release creation did not return an id. Response: $response"
return 1
fi
}
upload_release_assets() {
local release_id="$1"
local binary_path="$2"
local tarball_path="$3"
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping asset uploads."
return 0
fi
local token
token=$(tr -d '\n\r' < "$HOME/.gitea_token")
local assets_url="$GITEA_API_URL/releases/$release_id/assets"
if [[ -f "$binary_path" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
-F "name=$(basename "$binary_path")" > /dev/null
print_success "Uploaded binary: $(basename "$binary_path")"
fi
if [[ -f "$tarball_path" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \
-F "name=$(basename "$tarball_path")" > /dev/null
print_success "Uploaded tarball: $(basename "$tarball_path")"
fi
}
# --- Main ---------------------------------------------------------------
print_status "Starting version increment and push process..."
if [[ -n "$TARGET_VERSION" ]]; then
set_explicit_version
else
increment_version "$VERSION_INCREMENT_TYPE"
fi
print_status "New version: $NEW_VERSION"
if [[ "$RELEASE_MODE" == true ]]; then
# Build before committing so a failed build aborts the release.
if ! build_release_binary; then
print_error "Release build failed; aborting before push/upload"
exit 1
fi
git_commit_and_push
local_tarball=""
local_tarball=$(create_source_tarball || true)
register_cleanup "$local_tarball"
print_status "Creating Gitea release for $NEW_VERSION..."
release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
print_status "Uploading release assets (binary + tarball)..."
upload_release_assets "$release_id" "$RELEASE_BIN_PATH" "$local_tarball"
else
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
fi
print_success "Release flow completed: $NEW_VERSION"
else
git_commit_and_push
print_success "Increment and push completed: $NEW_VERSION"
fi
echo ""
echo "Done! Version $NEW_VERSION is live."
+189
View File
@@ -0,0 +1,189 @@
# Final Comprehensive Gap Analysis: sovereign_browser Rust Port
This document catalogs every remaining gap between the original C implementation (`/home/user/lt/sovereign_browser`) and the Rust port (`/home/user/lt/sovereign_browser_rust`). The goal is 100% feature parity.
---
## 1. Hamburger Menu Gaps
The C hamburger menu ([`tab_manager.c:2562-2692`](../sovereign_browser/src/tab_manager.c:2562)) has significantly more items than the Rust version ([`src/tab_manager.rs:997`](src/tab_manager.rs:997)).
### Missing Menu Items
| Item | C Action | Rust Status |
|------|----------|-------------|
| **Recents (History submenu)** | Dropdown of last 10 pages + "Clear Recents" | ❌ Missing |
| **Bookmarks submenu** | Tree of bookmarks + "Manage Bookmarks…" | ❌ Missing |
| **Tor-routed transport** | Check menu item (toggle Tor mode) | ❌ Missing |
| **FIPS mesh** | Check menu item (toggle FIPS mode) | ❌ Missing |
| **Security strip** | Check menu item (toggle SOP/CORS/certs) | ❌ Missing |
| **Nostr signing status** | Clickable item showing signer status | ❌ Missing |
| **Lock Session** | Sign out without closing browser | ❌ Missing |
| **Download Website** | Opens site downloader | ❌ Missing |
| **About** | Opens about dialog | ❌ Missing |
| **Avatar button** | Circular profile picture button next to hamburger | ❌ Missing |
### Required files: [`src/tab_manager.rs`](src/tab_manager.rs:997)
---
## 2. Keyboard Shortcuts Gaps
The C version defines 20+ shortcuts ([`shortcuts.c:24-124`](../sovereign_browser/src/shortcuts.c:24)). The Rust version ([`src/shortcuts.rs:21`](src/shortcuts.rs:21)) has 12.
### Missing Shortcuts
| Action | Shortcut | Rust Status |
|--------|----------|-------------|
| `new_window` | Ctrl+N | ❌ Not in shortcut registry |
| `open_file` | Ctrl+O | ❌ Missing |
| `force_reload` | Shift+F5 | ❌ Missing |
| `go_back` | Alt+Left | ❌ Missing |
| `go_forward` | Alt+Right | ❌ Missing |
| `find` | Ctrl+F | ❌ Missing |
| `open_settings` | Ctrl+, | ❌ Missing |
| `open_processes` | Ctrl+Shift+Esc | ❌ Missing |
| `new_identity` | Ctrl+Shift+U | ❌ Missing |
| `toggle_fullscreen` | F11 | ❌ Missing |
| `toggle_sidebar` | Ctrl+Shift+A | ❌ Missing |
| `toggle_toolbars` | Ctrl+Shift+M | ❌ Missing |
| `bookmark_page` | Ctrl+D | ❌ Missing |
| `show_history` | Ctrl+H | ❌ Missing |
| `show_bookmarks` | Ctrl+B | ❌ Missing |
| `show_downloads` | Ctrl+J | ❌ Missing |
| `quit` | Ctrl+Q | ❌ Missing |
| `next_tab_pagedown` | Ctrl+PageDown | ❌ Missing |
| `prev_tab_pageup` | Ctrl+PageUp | ❌ Missing |
### Key handler gaps in [`src/main.rs`](src/main.rs:130)
Missing: Ctrl+O (open_file), Ctrl+Comma (settings), Ctrl+Shift+Esc (processes), Ctrl+Shift+U (identity), Alt+Left/Right (back/forward), F5 (reload), Shift+F5 (force), Ctrl+F (find), Ctrl+D (bookmark), Ctrl+H (history), Ctrl+B (bookmarks), Ctrl+Q (quit), Ctrl+PageDown/Up (tabs), Ctrl+Shift+A (sidebar), Ctrl+Shift+M (toolbars)
### Missing `handle_key_press` bypass for `sovereign://` pages
The C version bypasses shortcut interception when `sovereign://` pages are active so the page's JS can capture key combos.
---
## 3. Settings Page (`sovereign://settings`) Gaps
### `settings/config` JSON gaps ([`src/nostr_bridge.rs:465`](src/nostr_bridge.rs:465))
| Field | Type | C version ([`nostr_bridge.c:2940`](../sovereign_browser/src/nostr_bridge.c:2940)) | Rust Status |
|-------|------|-----------|-------------|
| `agent_server_enabled` | bool | Whether agent server is on | ❌ Missing |
| `agent_login_timeout_ms` | number | Login timeout | ❌ Missing |
| `dev_extras` | bool | From WebKit settings | ❌ Missing |
| `file_access` | bool | From WebKit settings | ❌ Missing |
| `universal_access` | bool | From WebKit settings | ❌ Missing |
| `search_engines` | array | List of {id, name} | ❌ Missing |
| `shortcuts` | array | All shortcuts with id/label/accel | ❌ Missing |
| `tab_bar_position` | string | "top"/"bottom"/"left"/"right" | ❌ Returns number |
### `settings/set` handler gaps ([`src/nostr_bridge.rs:465`](src/nostr_bridge.rs:465))
| Feature/Key | C handler ([`nostr_bridge.c:1037`](../sovereign_browser/src/nostr_bridge.c:1037)) | Rust Status |
|-------------|-----------|-------------|
| `agent_server_enabled` | Toggle agent server on/off | ❌ Missing |
| `dev_extras` | Toggle WebKit developer extras | ❌ Missing |
| `file_access` | Toggle file access from file:// | ❌ Missing |
| `universal_access` | Toggle universal access from file:// | ❌ Missing |
| `shortcut.reset_all` | Reset all shortcuts to defaults | ❌ Missing |
| `shortcut.reset` | Reset a single shortcut | ❌ Missing |
| `shortcut.<action>` | Set a shortcut to a custom binding | ❌ Missing |
| `agent_allowed_origins` | Save allowed origins | ❌ Missing |
| `agent_login_timeout_ms` | Save login timeout | ❌ Missing |
### Settings page HTML
The `www/settings.html` + `settings.js` expects the `settings/config` JSON to include `search_engines`, `shortcuts`, `agent_server_enabled`, `dev_extras`, etc. Without these fields, the page renders incompletely.
---
## 4. Agent System Gaps
| Feature | C Status | Rust Status |
|---------|----------|-------------|
| **Agent chat sidebar** | Lazily created webview in GtkPaned, loads `sovereign://agents/chat` | ✅ Implemented ([`src/tab_manager.rs:1076`](src/tab_manager.rs:1076)) |
| **Agent tools** | `browser_click`, `browser_type`, `browser_screenshot` | ✅ Implemented ([`src/agent_tools.rs:323`](src/agent_tools.rs:323)) |
| **Agent loop** | Async LLM loop with tool calling | ✅ Implemented ([`src/agent_loop.rs`](src/agent_loop.rs)) |
| **Agent LLM providers** | OpenAI/Anthropic/Ollama support | ✅ Implemented ([`src/agent_llm.rs`](src/agent_llm.rs)) |
| **Agent chat store** | Message history, conversation management | ✅ Implemented ([`src/agent_chat_store.rs`](src/agent_chat_store.rs)) |
| **Agent conversations** | Save/load/rename/delete conversations | ✅ Implemented ([`src/agent_conversations.rs`](src/agent_conversations.rs)) |
| **Agent skills** | Kind 31123 skill management | ✅ Implemented ([`src/agent_skills.rs`](src/agent_skills.rs)) |
| **Agent snapshot** | MCP snapshot of tool state | ✅ Implemented ([`src/agent_snapshot.rs`](src/agent_snapshot.rs)) |
| **Agent server** | HTTP server for agent tools | ✅ Implemented ([`src/agent_server.rs`](src/agent_server.rs)) |
| **Agent login** | Extension login state | ✅ Implemented ([`src/agent_login.rs`](src/agent_login.rs)) |
| **Agent MCP** | Model Context Protocol server | ✅ Implemented ([`src/agent_mcp.rs`](src/agent_mcp.rs)) |
| **Agent FS tools** | Filesystem tool definitions | ✅ Implemented ([`src/agent_fs_tools.rs`](src/agent_fs_tools.rs)) |
---
## 5. `sovereign://` Internal Page Gaps
| Page | C Status | Rust Status |
|------|----------|-------------|
| `sovereign://settings` | Full settings with all toggles, relays, search, shortcuts | ✅ HTML served from `www/settings.html`, JSON endpoints need config fields |
| `sovereign://bookmarks` | Tree view with add/delete/create/move/rename | ✅ Implemented ([`src/nostr_bridge.rs:95`](src/nostr_bridge.rs:95)) |
| `sovereign://profile` | Kind 0/3/10002 data from SQLite | ✅ Implemented ([`src/nostr_bridge.rs:386`](src/nostr_bridge.rs:386)) |
| `sovereign://processes` | Layer 1 (OS) + Layer 2 (tabs) | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) |
| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Served from `www/fips.html` + JS, backend stubs |
| `sovereign://agents` | Provider config, models, skills | ✅ Served from `www/agents/config.html` + JS |
| `sovereign://agents/chat` | Full chat client with markdown, conversations | ✅ Served from `www/agents/chat.html` + JS |
| `sovereign://qr` | QR code generation | ✅ Implemented ([`src/nostr_bridge.rs:419`](src/nostr_bridge.rs:419)) |
| `sovereign://security` | Redirects to settings | ✅ Implemented |
| `sovereign://nostr/*` | NIP-07 bridge API | ✅ Implemented ([`src/nostr_bridge.rs:89`](src/nostr_bridge.rs:89)) |
---
## 6. Network Services Gaps
| Service | C Status | Rust Status |
|---------|----------|-------------|
| **Tor control** | TCP/Unix socket, authenticate, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) |
| **FIPS control** | Unix socket JSON-line protocol | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) |
| **Net services** | Start/stop Tor & FIPS managed services | Stubs ([`src/net_services.rs`](src/net_services.rs)) |
---
## 7. Performance Probe Gaps
| Feature | C Status | Rust Status |
|---------|----------|-------------|
| **Perf-probe.js injection** | Preamble + probe body injected into every non-internal page | ✅ Implemented ([`src/nostr_inject.rs:135`](src/nostr_inject.rs:135)) |
| **probe-report endpoint** | Receives periodic probe reports | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) |
| **tab_probe endpoint** | Drill-down for a single tab | ✅ Implemented |
| **tab_action endpoint** | Reload/suspend/close tabs | ✅ Implemented |
---
## 8. Minor Gaps
| Item | C Status | Rust Status |
|------|----------|-------------|
| **Site downloader** | Headless webview rendering & asset bundling | Stub ([`src/site_downloader.rs`](src/site_downloader.rs)) |
| **Favicon display** | 16x16 pixbufs in tab labels | ❌ Missing (cairo API limitation) |
| **Popup handler** | `target="_blank"` → new tab policy | ❌ WebKit `create` signal not hooked |
| **URL history tracking** | Records visited URLs in SQLite | ❌ Not wired to `load-changed` signal |
| **Avatar button** | Circular profile picture in toolbar | ❌ Missing |
| **About dialog** | GTK dialog with version info | ❌ Missing |
---
## Summary: What Needs to Be Done
### High Priority (Blocks user experience)
1. **Hamburger menu parity** — Add Recents (history), Bookmarks tree, Tor/FIPS toggles, Security strip toggle, Nostr signing status, Lock Session, Download Website, About, Avatar button.
2. **Shortcut parity** — Add all missing 17 shortcuts to registry and key handler.
3. **Settings/config JSON** — Add missing fields: `agent_server_enabled`, `agent_login_timeout_ms`, `dev_extras`, `file_access`, `universal_access`, `search_engines`, `shortcuts`, `tab_bar_position` (string).
4. **Settings/set handler** — Add missing features: `agent_server_enabled`, `dev_extras`, `file_access`, `universal_access`, shortcut reset, shortcut set.
### Medium Priority
5. **Popup handler** — Hook WebKit `create` signal for `target="_blank"`.
6. **URL history** — Wire `load-changed` signal to `db_add_history`.
7. **Site downloader** — Implement headless webview rendering.
### Low Priority
8. **Favicon display** — Research cairo surface conversion.
9. **About dialog** — Simple GTK dialog.
10. **Avatar button** — Circular profile picture.
+127
View File
@@ -0,0 +1,127 @@
# Comprehensive Implementation Gap Analysis: Sovereign Browser (Rust vs. C)
This document provides a detailed breakdown of all components, subsystems, internal pages, and features comparing the original C implementation (`/home/user/lt/sovereign_browser`) to the current Rust implementation (`/home/user/lt/sovereign_browser_rust`).
---
## 1. Executive Summary & Parity Matrix
| Subsystem / Feature | C Implementation Status | Rust Implementation Status | Parity % | Priority |
|---------------------|-------------------------|----------------------------|----------|----------|
| **Core GTK & Multi-Tab Window** | Complete (tabs, toolbars, shortcuts, theme, context menus) | Complete (tabs, per-tab toolbars, theme, webview & tab context menus) | **95%** | Low |
| **NIP-07 JS Extension Injection (`window.nostr`)** | Complete (Sync XHR, Security Manager, NIP-04/44, named constructor) | Complete (Sync XHR, Security Manager, NIP-04/44, named constructor) | **100%** | Done |
| **MCP Automation Server** | Complete (Streamable HTTP, Tools on port 17777) | Complete (Streamable HTTP, cross-thread GTK scheduling) | **100%** | Done |
| **Login Dialog & CLI Auth** | Complete (5 tabs, No Login, CLI auto-login for local/seed/pubkey) | Complete (5 tabs, No Login, CLI auto-login for local/seed/pubkey) | **95%** | Low |
| **Settings (`sovereign://settings`)** | Complete (Embedded HTML/JS, Live toggles, relays, shortcuts, save) | Complete (Dynamic HTML, XHR endpoint handling, SQLite persistence) | **90%** | Low |
| **Embedded Internal Pages (`sovereign://`)** | Complete (Profile, Bookmarks, FIPS, Processes, Agents) | Partial (Basic static HTML/JS stubs, missing rich JS apps) | **30%** | **High** |
| **Agent Loop & Provider Management** | Complete (OpenAI/Anthropic/Ollama, tool calling, chat store) | Partial (Async loop structure exists, missing UI hooks & prompt wiring) | **50%** | **High** |
| **Agent Chat Sidebar** | Complete (Collapsible left sidebar webview running `sovereign://agents/chat`) | Incomplete (Shortcut mapped, sidebar container not fully attached to tabs) | **20%** | **Medium** |
| **URL Autocomplete / Search Dropdown** | Complete (GtkEntryCompletion for bookmarks & history) | Incomplete (URL entry exists, completion model not wired) | **20%** | **Medium** |
| **Per-Tab Performance Probe (`sovereign://processes`)** | Complete (`perf-probe.js` injection, CPU/FPS/DOM stats, probe-report endpoint) | Incomplete (Skeleton probe struct, probe injection & report collection missing) | **15%** | **Medium** |
| **NIP-78 Settings & Bookmark Sync** | Complete (Kind 30078 / 30003 NIP-44 encrypted sync to bootstrap relays) | Partial (Library functions exist, automatic background publish not wired) | **40%** | **Medium** |
| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Stubs (Module placeholders returning mock/default status) | **15%** | **Low** |
| **Offline Site Downloader** | Complete (Headless webview rendering & asset bundling) | Stubs (Log message placeholder) | **10%** | **Low** |
---
## 2. Detailed Gap Breakdown by Subsystem
### 2.1 Internal Pages & Embedded Assets (`src/embedded_web_content.rs`, `src/nostr_bridge.rs`)
In the original C version, the `www/` directory contains complete single-page web applications for browser management:
1. **`sovereign://profile` (`www/profile.html`, `profile.js`, `profile.css`)**:
- **C**: Queries cached Kind 0 (metadata) and Kind 3 (contacts) events from SQLite, displays avatar, banner, bio, NIP-05 verification, QR code generation (`sovereign://qr`), and contact list.
- **Rust**: Only returns a minimal static HTML template; `handle_profile_data_json` endpoint is stubbed.
2. **`sovereign://bookmarks` (`www/bookmarks.html`, `bookmarks.js`, `bookmarks.css`)**:
- **C**: Nested folder tree view with drag-and-drop hierarchy, tag filters, live editing, and REST endpoints (`/bookmarks/list`, `/add`, `/delete`, `/createdir`, `/deletedir`, `/move`, `/rename`, `/renamedir`).
- **Rust**: Embedded HTML is a simple flat list; directory tree endpoints need full implementation in `nostr_bridge.rs`.
3. **`sovereign://agents` & `sovereign://agents/chat` (`www/agents/`)**:
- **C**: Complete chat client supporting markdown rendering (`marked.min.js`), HTML sanitization (`purify.min.js`), conversation branching, model selection, skill management (`/agents/skills/*`), and live polling of agent messages.
- **Rust**: Static placeholder forms without full web client assets and conversation endpoints.
4. **`sovereign://processes` (`www/processes.html`, `processes.js`)**:
- **C**: Two-layer diagnostics dashboard: Layer 1 lists OS processes/WebProcesses (PID, CPU, Memory); Layer 2 lists per-tab performance metrics collected via `perf-probe.js`.
- **Rust**: HTML skeleton with no real process tracking or probe integration.
5. **`sovereign://fips` (`www/fips.html`, `fips.js`)**:
- **C**: Mesh management UI showing status cards, active peers, spanning tree graph, node directory (Kind 37195), and manual connection controls.
- **Rust**: Static placeholder form.
---
### 2.2 Tab Management & UI Features (`src/tab_manager.rs`)
1. **Agent Chat Sidebar (`tab_manager_toggle_sidebar`)**:
- **C**: Creates a persistent GtkPaned container with a collapsible 280px left sidebar hosting a dedicated WebKitWebView loaded with `sovereign://agents/chat`.
- **Rust**: `tab_manager_toggle_sidebar()` function stub exists, but webview/sidebar layout is not packed into the tab container.
2. **URL Entry Completion (`GtkEntryCompletion`)**:
- **C**: As the user types in the URL bar, an async completion popup searches SQLite history and bookmarks, showing favicon/title/URL.
- **Rust**: `GtkEntry` handles Enter and Esc, but has no attached `GtkEntryCompletion` model.
3. **Popup & New Window Handling (`on_create_webview` / `target="_blank"`)**:
- **C**: Intercepts WebKit `create` signal on webviews to enforce tab policy (converting popup windows into new tabs or respected multi-window popups based on settings).
- **Rust**: Navigation policy decision is handled for URLs, but the WebKit `create` signal is not hooked.
4. **Favicon Display**:
- **C**: Captures `favicon-database` updates and renders 16x16 pixbufs in tab labels.
- **Rust**: Omitted due to Cairo surface conversion differences; uses a generic globe icon.
---
### 2.3 Agent System & Tool Catalog (`src/agent_*.rs`)
1. **Interactive Tool Execution**:
- `browser_click` and `browser_type`: Currently stubs in `agent_tools.rs`. C injects DOM query selector JS to synthesize click and keyboard input events.
- `browser_screenshot`: C captures the WebKit webview surface to PNG bytes; Rust returns a stub message.
2. **Skill Management (`src/agent_skills.rs`)**:
- C publishes and queries NIP-51/NIP-78 Kind 31123 skill definitions to Nostr relays. Rust has internal structs but lacks relay publishing integration.
3. **Conversation Persistence (`src/agent_conversations.rs`)**:
- C encrypts chat sessions using NIP-44 and persists them to Nostr relays (Kind 30078) and local SQLite. Rust has SQLite tables but lacks relay publishing.
---
### 2.4 Performance Probe Subsystem (`src/perf_probe.rs`)
1. **`js/perf-probe.js` Script Injection**:
- **C**: Injected via `UserContentManager` into non-internal pages; measures CPU busy percentage (Long Tasks API), FPS (requestAnimationFrame loop), DOM node count, Heap used, in-flight fetch requests, and sends periodic reports to `sovereign://processes/probe-report`.
- **Rust**: Not yet injected; `/processes/probe-report` endpoint is unhandled.
---
### 2.5 Network Services & System daemons (`src/net_services.rs`, `src/tor_control.rs`, `src/fips_control.rs`)
1. **Tor Control**:
- **C**: Connects to Tor ControlPort (default 9051), authenticates, checks circuit status, and requests new identity (`SIGNAL NEWNYM`).
- **Rust**: Simulated in memory.
2. **FIPS Mesh Control**:
- **C**: Connects via Unix domain socket to the local FIPS daemon, queries peer tables, resolves `.fips` and npub overlay addresses.
- **Rust**: Stubs returning default status.
---
## 3. Recommended Roadmap for Full Parity
```mermaid
flowchart TD
A[Phase 1: Rich Embedded Pages & Assets] --> B[Phase 2: UI Enhancements & Sidebar]
B --> C[Phase 3: Agent Tool Execution & Automation]
C --> D[Phase 4: Diagnostics & Settings/Bookmark Sync]
D --> E[Phase 5: Network Services Tor/FIPS]
```
### Phase 1: Rich Internal Pages (`sovereign://`)
- Migrate embedded web assets from `www/` into `embedded_web_content.rs` or asset files.
- Implement full REST JSON route dispatchers in `nostr_bridge.rs` for Bookmarks (`/bookmarks/*`), Profile (`/profile/data`), and Agents (`/agents/*`).
### Phase 2: Tab Manager & UI Features
- Wire collapsible Agent Chat sidebar into `tab_manager.rs`.
- Connect `GtkEntryCompletion` for URL autocomplete from SQLite history and bookmarks.
- Handle WebKit `create` signal for `target="_blank"` popups.
### Phase 3: Agent Automation & Tool Execution
- Implement DOM automation tools (`browser_click`, `browser_type`) via `run_javascript`.
- Implement `browser_screenshot` via WebKitGTK snapshot API.
### Phase 4: Performance Probe & Sync
- Wire `perf-probe.js` injection into all page views and connect `sovereign://processes/probe-report`.
- Wire automatic background sync for Kind 30078 settings and Kind 30003 bookmarks.
### Phase 5: Network Daemon Controls
- Implement Tor ControlPort protocol client in `tor_control.rs`.
- Implement FIPS daemon Unix socket IPC in `fips_control.rs`.
+225
View File
@@ -0,0 +1,225 @@
# Login Dialog Comparison: Original (C) vs Rust Implementation
**Date:** 2026-08-17
**Scope:** The Nostr login dialog shown when the browser starts.
**Reference files:**
- Original (C): [`sovereign_browser/src/login_dialog.c`](../sovereign_browser/src/login_dialog.c)
- Rust: [`src/login_dialog.rs`](../src/login_dialog.rs)
---
## 1. Overview
Both implementations present a modal GTK dialog on startup that lets the user
authenticate with a Nostr identity before the browser becomes usable. The
original C implementation is a mature, feature-complete dialog with **five
method tabs** plus a **"No Login"** option. The Rust implementation is a
minimal port that currently supports only **two** of the five methods and
lacks most of the original's UI affordances.
The Rust dialog is a functional but **visually and functionally reduced**
version of the original. This document details, page by page, what each
login screen shows and the differences between the two implementations.
---
## 2. High-Level Structural Differences
| Aspect | Original (C) | Rust |
|---|---|---|
| Window title | `sovereign browser <version>` (e.g. `sovereign browser v0.0.70`) | `Nostr Login` |
| Default size | 560 × 380 | 500 × 400 |
| Method selection | **GtkNotebook tabs** (Local Key, Seed Phrase, Read-only, NIP-46, n_signer) | **GtkComboBoxText dropdown** (5 entries) |
| "No Login" button | ✅ Present (far left) | ❌ Absent |
| Buttons | `No Login` \| `Cancel` \| `Sign In` | `Cancel` \| `Login` |
| Title header | Bold markup: **"Sign in with your Nostr key"** | ❌ None |
| Status/error label | ✅ Present (shows validation errors inline) | ❌ None (silently returns `None` on error) |
| Monospace font styling | ✅ Applied via CSS provider | ❌ None |
| Agent auto-close (200ms poll) | ✅ Present | ❌ None |
| Methods actually implemented | All 5 (local, seed, readonly, nip46, nsigner) | Only 2 (local, readonly) |
| Error messaging | Detailed inline messages | Silent failure (returns `None`) |
---
## 3. Page-by-Page Comparison
### 3.1 Local Key
**Original (C)** — tab label **"Local Key"**
- Instruction label: *"Enter your Nostr private key (nsec) or generate a new one:"*
- Text entry with placeholder `nsec1...`, width 60 chars
- **"Generate New Key"** button — generates a fresh keypair and fills the entry with the `nsec`
- Accepts `nsec1...` (bech32) **or** 64-char hex private key
- Validates input; shows inline errors: *"Invalid nsec."*, *"Invalid hex private key."*, *"Enter an nsec1... or 64-char hex key."*, *"Enter an nsec or click Generate."*
**Rust** — dropdown entry **"Local Key (nsec)"**
- Label: *"Private Key (hex):"*
- Text entry with **password masking** (`set_visibility(false)`)
- ❌ No "Generate New Key" button
- ❌ Only accepts 64-char **hex** private key (no `nsec1...` bech32 support)
- ❌ No inline validation/error messages — invalid input silently returns `None`
**Key differences:**
1. Rust only accepts hex, not `nsec1...` bech32.
2. Rust has no key generation button.
3. Rust masks the input as a password; the original shows it in plain text.
4. Rust has no error feedback.
---
### 3.2 Seed Phrase
**Original (C)** — tab label **"Seed Phrase"**
- Instruction label: *"Enter your 12-word BIP-39 seed phrase:"*
- **12 numbered word entry boxes in a 4×3 grid** (1. through 12.)
- Each box has **dropdown completion** (BIP-39 wordlist) + **inline auto-complete** on typing
- **Tab/Enter** moves focus to the next word box
- **"Generate New Mnemonic"** button — fills all 12 boxes with a fresh mnemonic
- **Account #** spin button (0–1000) with live derivation hint: *"Key derivation: m/44'/1237'/<N>'/0/0"*
- Validates exactly 12 words; errors: *"Expected 12 words, got N."*, *"Invalid seed phrase. Check the words and try again."*
**Rust** — dropdown entry **"Seed Phrase (BIP-39)"**
- Label: *"Seed Phrase (BIP-39):"*
- Single text entry with **password masking**
- ❌ No 12-box grid, no word completion, no auto-complete, no Tab navigation
- ❌ No "Generate New Mnemonic" button
- ❌ No Account # selector or derivation path hint
- ❌ **Not implemented** — selecting this method and clicking Login returns `None` (the `Seed` arm is not handled in the match)
**Key differences:**
1. Rust does not implement seed-phrase login at all (falls through to `None`).
2. The original's rich 12-word grid with completion is entirely absent.
---
### 3.3 Read-only (npub)
**Original (C)** — tab label **"Read-only"**
- Instruction label: *"Enter a Nostr public key (npub) for read-only mode:"*
- Text entry with placeholder `npub1...`, width 60 chars
- Hint (dimmed): *"Read-only mode: you can view content but cannot sign events."*
- Accepts `npub1...` (bech32) **or** 64-char hex pubkey
- Validates; errors: *"Invalid npub."*, *"Invalid hex pubkey."*, *"Enter an npub1... or 64-char hex pubkey."*
**Rust** — dropdown entry **"Read-only (npub)"**
- Label: *"Public Key (npub or hex):"*
- Text entry (visible, not masked)
- ✅ Accepts `npub1...` (via `nostr_url_normalize`) or hex
- ❌ No hint text
- ❌ No inline validation/error messages
**Key differences:**
1. Rust supports this method (the only one besides local that works).
2. Rust lacks the explanatory hint and error feedback.
---
### 3.4 NIP-46 Remote Signer
**Original (C)** — tab label **"NIP-46"**
- Instruction label: *"Connect to a NIP-46 remote signer (bunker:// URL):"*
- Text entry with placeholder `bunker://<pubkey>?relay=wss://...&secret=...`, width 60 chars
- Wrapped hint (dimmed): *"The remote signer holds your private key. Signing requests are sent over Nostr relays."*
- Parses and validates the bunker URL; errors: *"Invalid bunker:// URL. Format: ..."*, *"Enter a bunker:// URL."*
- Generates a client keypair, stores the remote signer's pubkey as identity
**Rust** — dropdown entry **"NIP-46 Remote Signer"**
- Label: *"Bunker URL:"*
- Text entry (visible)
- ❌ **Not implemented** — the `Nip46` arm is not handled in the match, returns `None`
- ❌ No hint text, no URL validation
**Key differences:**
1. Rust does not implement NIP-46 login at all.
---
### 3.5 n_signer Hardware
**Original (C)** — tab label **"n_signer"**
- Instruction label: *"Connect to n_signer hardware signer:"*
- **Transport** combo: `USB Serial` / `UNIX Socket` / `TCP` / `Other Qube` (default: Other Qube)
- **Device** entry — label/placeholder adapts to transport:
- Serial: *"Device Path:"* / `/dev/ttyACM0` + **"Detect Serial Devices"** button
- UNIX: *"Socket Name:"* / `nsigner`
- TCP: *"Host:Port:"* / `127.0.0.1:7777`
- Qube: *"Target Qube:"* / `nostr_signer` (default value)
- **Service** entry (qrexec only): `qubes.NsignerRpc`
- **Role** entry: `nostr_range`
- **Key Index** spin (0–1000) with live BIP-32 path label: *"Key Index (m/44'/1237'/<N>'/0/0):"*
- Wrapped hint (dimmed): *"n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device."*
- Detailed error mapping (auth denied, policy denied, index not whitelisted, etc.)
**Rust** — dropdown entry **"n_signer Hardware"**
- Label: *"Device Path:"*
- Text entry (visible)
- ❌ **Not implemented** — the `Nsigner` arm is not handled in the match, returns `None`
- ❌ No transport selector, no service/role/index fields, no device detection
**Key differences:**
1. Rust does not implement n_signer login at all.
2. The original's transport-adaptive UI is entirely absent.
---
## 4. Feature Matrix
| Feature | Original (C) | Rust |
|---|---|---|
| Local key (nsec/hex) | ✅ | ⚠️ hex only |
| Local key generation | ✅ | ❌ |
| Seed phrase (BIP-39) | ✅ | ❌ |
| Seed word grid + completion | ✅ | ❌ |
| Read-only (npub) | ✅ | ✅ |
| NIP-46 remote signer | ✅ | ❌ |
| n_signer hardware | ✅ | ❌ |
| "No Login" (browse w/o identity) | ✅ | ❌ |
| Inline error/status messages | ✅ | ❌ |
| Method tabs (notebook) | ✅ | ❌ (dropdown) |
| Title header | ✅ | ❌ |
| Monospace styling | ✅ | ❌ |
| Agent auto-close | ✅ | ❌ |
| Account # / key index selectors | ✅ | ❌ |
---
## 5. Summary of Gaps
The Rust login dialog is a **minimal skeleton** compared to the original.
The most significant gaps, in priority order:
1. **Only 2 of 5 methods work** — `Seed`, `Nip46`, and `Nsigner` are listed in
the dropdown but return `None` when selected. The original implements all five.
2. **No "No Login" option** — the original lets users browse without a Nostr
identity; the Rust version has no equivalent.
3. **No error feedback** — the original shows inline validation/error messages;
the Rust version silently returns `None` on any failure, giving the user no
indication of what went wrong.
4. **Reduced local-key support** — Rust only accepts hex private keys (no
`nsec1...` bech32) and has no "Generate New Key" button.
5. **UI structure differs** — the original uses notebook tabs with rich per-method
screens (word grids, transport selectors, derivation hints); Rust uses a single
dropdown that swaps a single label/entry.
6. **Missing polish** — no title header, no monospace styling, no agent
auto-close, no version in the window title.
---
## 6. Recommended Path to Parity
To bring the Rust dialog in line with the original:
1. **Implement the missing methods** (`Seed`, `Nip46`, `Nsigner`) in
[`src/login_dialog.rs`](../src/login_dialog.rs) and
[`src/key_store.rs`](../src/key_store.rs) (`key_store_create_signer` already
returns `None` for these — see [`src/key_store.rs`](../src/key_store.rs:108)).
2. **Add a "No Login" button** that returns a `None`-method result so the browser
can run without an identity.
3. **Add a status/error label** and surface validation errors instead of
returning `None` silently.
4. **Add `nsec1...` bech32 support** to the local-key path and a
"Generate New Key" button.
5. **Switch from a dropdown to notebook tabs** to match the original's layout,
and add the per-method helper widgets (word grid, transport selector, etc.).
6. **Restore polish**: title header, monospace CSS, version in the window title,
and the agent auto-close timeout.
+322
View File
@@ -0,0 +1,322 @@
# Main Browser Window Comparison: Original (C) vs Rust Implementation
**Date:** 2026-08-17
**Scope:** The main browser window — how tabs are generated, how new windows are
generated, and the surrounding UI chrome (toolbars, menus, tab strip, sidebar).
**Reference files:**
- Original (C): [`sovereign_browser/src/main.c`](../sovereign_browser/src/main.c) and
[`sovereign_browser/src/tab_manager.c`](../sovereign_browser/src/tab_manager.c)
- Rust: [`src/main.rs`](../src/main.rs) and [`src/tab_manager.rs`](../src/tab_manager.rs)
---
## 1. Overview
The original C implementation is a mature, feature-complete multi-window
browser. Each tab carries its **own toolbar** (hamburger menu, refresh/stop,
back/forward, URL entry with search completion, bookmark button), a **bookmark
bar**, a **load-progress bar**, a rich **tab label** (favicon + title + close
button), and a **right-click tab context menu**. It supports **multiple
top-level windows**, each with its own notebook, a **per-window sidebar**, tab
**drag-reordering**, and a full **hamburger menu** with identity, network,
security, and internal-page items.
The Rust implementation is a **minimal single-window skeleton**. It has a
single shared toolbar with one URL entry and a Settings button, a plain
`GtkNotebook` with simple text labels, and no per-tab chrome, no context menus,
no new-window support, no sidebar, and no drag-reordering. This document details
the differences and what would be needed to bring the Rust project up to the
same level.
---
## 2. High-Level Structural Differences
| Aspect | Original (C) | Rust |
|---|---|---|
| Windows | **Multi-window** (main + auxiliary `GtkWindow`s) | **Single window** only |
| Per-tab toolbar | ✅ Each tab has its own toolbar | ❌ One shared toolbar for all tabs |
| Tab label | Favicon + title + close button (rich) | Plain text label only |
| Tab context menu (right-click) | ✅ New / Close / Close Others / Close to Right / Open in New Window / Duplicate / Reload / Download Website | ❌ None |
| Middle-click to close | ✅ (configurable) | ❌ |
| Tab drag-reordering | ✅ (configurable) | ❌ |
| New-tab button on tab strip | ✅ (`tab-new-symbolic` action widget) | ❌ |
| User avatar on tab strip | ✅ (Nostr profile picture) | ❌ |
| Hamburger menu | ✅ Full menu (identity, network, security, internal pages) | ❌ (only a Settings button) |
| Sidebar (agent chat) | ✅ Per-window `GtkPaned` sidebar | ❌ |
| Bookmark bar | ✅ Per-tab bookmark bar | ❌ |
| Load-progress bar | ✅ Per-tab thin progress bar | ❌ |
| URL search completion | ✅ History + bookmarks + search suggestions | ❌ (plain entry) |
| Back/Forward/Refresh buttons | ✅ Per-tab | ❌ |
| `target="_blank"` / `window.open()` | ✅ New tab or new window via `create` signal | ❌ (not handled) |
| Keyboard shortcuts | ✅ Full set (Ctrl+T/N/W, zoom, inspector, sidebar, fullscreen, etc.) | ⚠️ Defined in data but **not wired** to any handler |
| Session restore | ✅ With CLI precedence logic | ⚠️ Basic (restores URLs) |
| App theme / CSS | ✅ Custom red-accent theme | ❌ |
| Window close semantics | ✅ Multi-window aware (app quits only when last window closes) | ⚠️ Simple (quits on main window close) |
---
## 3. Tab Generation
### 3.1 Original (C) — `tab_create()` ([`tab_manager.c`](../sovereign_browser/src/tab_manager.c:2993))
Each tab is a full `tab_info_t` with its own page (vertical box) containing:
1. **Per-tab toolbar** (horizontal box, name `main-toolbar`):
- **Hamburger menu** button (`build_hamburger_menu`)
- **Refresh/Stop button** — left-click reloads, right-click shows hard-reload
options (bypass cache, clear cookies + reload); icon/tooltip sync with
loading state
- **Back button** and **Forward button**
- **URL entry** with **search completion** (history + bookmarks + domain
heuristic + async search-engine suggestions)
- **Bookmark button** (opens a directory picker to bookmark the page)
2. **Bookmark bar** — buttons for the "Bookmarks Bar" folder + subfolder popovers
3. **Load-progress bar** — thin 3px bar shown during loads
4. **WebView** — with developer extras, hardware acceleration, smooth scrolling,
JSON viewer injection, `window.nostr` injection, and a battery of signal
handlers (load-changed, load-failed, favicon, title, context-menu,
decide-policy, create, key-press, button-press)
5. **Tab label** (`build_tab_label`) — favicon + ellipsized title + close button,
with middle-click/right-click handling on the label and all children
### 3.2 Rust — `tab_manager_new_tab()` ([`src/tab_manager.rs`](../src/tab_manager.rs:48))
Each tab is a `TabInfo` struct plus a `GtkScrolledWindow` containing a bare
`WebView`. The tab label is a plain `gtk::Label` ("New Tab" → page title). There
is **no per-tab toolbar, no bookmark bar, no progress bar, no favicon, no close
button, no context menu, no drag-reordering**.
### 3.3 Differences
| Feature | Original (C) | Rust |
|---|---|---|
| Per-tab toolbar | ✅ | ❌ |
| URL entry per tab | ✅ | ❌ (one shared) |
| Back/Forward/Refresh buttons | ✅ | ❌ |
| Search completion in URL bar | ✅ | ❌ |
| Bookmark button + bar | ✅ | ❌ |
| Load-progress bar | ✅ | ❌ |
| Favicon in tab label | ✅ | ❌ |
| Close button on tab | ✅ | ❌ |
| Tab context menu | ✅ | ❌ |
| Middle-click close | ✅ | ❌ |
| Drag-reorder | ✅ | ❌ |
| Max-tabs limit | ✅ (`settings.max_tabs`) | ❌ |
| Focus URL entry on new tab | ✅ | ❌ |
| Per-tab signal wiring | ✅ (load/favicon/title/context/create/decide-policy) | ⚠️ (title only) |
---
## 4. New Window Generation
### 4.1 Original (C) — `tab_manager_new_window()` ([`tab_manager.c`](../sovereign_browser/src/tab_manager.c:1263))
Creates a **real top-level `GtkWindow`** with:
- Its own `GtkNotebook` (full tab infrastructure via `tab_create`)
- New-tab button + avatar as notebook action widgets
- A **window-level `GtkPaned`** (left = sidebar container, right = notebook)
- A full tab created with `webkit_web_view_new_with_related_view()` (shares the
parent's WebProcess, avoiding the `WindowFeatures` assertion crash)
- Focus-in / destroy handlers that track the active window and revert to the
main window when an auxiliary window closes (without quitting the app)
- A `window_state_t` registered in `g_aux_windows`
New windows are created from:
- **Ctrl+N** / `SHORTCUT_NEW_WINDOW` → `tab_manager_new_window_blank()`
- **Tab context menu** "Open in New Window" → `tab_manager_open_in_new_window()`
- **`target="_blank"` / `window.open()`** → `on_create_webview()` creates a new
tab (or window) via the WebKit `create` signal
### 4.2 Rust — no new-window support
The Rust implementation has **no new-window function at all**. There is no
`tab_manager_new_window`, no `create`-signal handler, and no `Ctrl+N` handling.
`target="_blank"` links and `window.open()` are not handled, so they either do
nothing or open in the same view depending on WebKit defaults.
### 4.3 Differences
| Feature | Original (C) | Rust |
|---|---|---|
| Multiple top-level windows | ✅ | ❌ |
| `Ctrl+N` new window | ✅ | ❌ |
| "Open in New Window" (context menu) | ✅ | ❌ |
| `target="_blank"` → new tab/window | ✅ | ❌ |
| Per-window notebook | ✅ | ❌ |
| Per-window sidebar | ✅ | ❌ |
| Active-window tracking (focus-in) | ✅ | ❌ |
| App stays alive when aux window closes | ✅ | ❌ |
| Related-view webview (crash avoidance) | ✅ | ❌ |
---
## 5. Window Chrome & Menus
### 5.1 Hamburger menu (Original only)
The C version's per-tab hamburger menu ([`build_hamburger_menu`](../sovereign_browser/src/tab_manager.c:2562))
contains:
- **Navigation**: Open File…, Reload, Stop
- **Recents** submenu (history)
- **Bookmarks** submenu
- **Identity**: Switch Identity…, Lock Session, Logout
- **Networking**: Tor-routed transport, FIPS mesh (check items, synced across tabs)
- **Security/status**: Security strip (SOP/CORS/certs), Nostr signing status
- **Tools**: Toggle Inspector, Toggle Agent Sidebar
- **Internal pages**: Profile, Agent Setup…, FIPS Mesh…, Processes…, Settings…, About
The Rust version has only a single **"Settings"** toolbar button that opens
`sovereign://settings` in a new tab. There is no hamburger menu, no identity
menu, no network toggles, no internal-page menu.
### 5.2 Tab strip action widgets (Original only)
The C version adds to each notebook:
- A **new-tab button** (`tab-new-symbolic`) at the end of the tab strip
- A **user avatar** button at the start (Nostr profile picture, circular)
The Rust version has neither.
### 5.3 Sidebar (Original only)
The C version has a **per-window `GtkPaned` sidebar** (agent chat), hidden by
default, toggled via Ctrl+Shift+A / menu / `;` shortcut. The Rust version has no
sidebar.
---
## 6. Keyboard Shortcuts
### 6.1 Original (C)
The C version wires a full `on_key_press` handler ([`main.c`](../sovereign_browser/src/main.c:501))
to both the window and every webview, dispatching through the configurable
`shortcuts_lookup()` table. Actions include: New Tab, New Window, Open File,
Close Tab, Focus URL, Next/Prev Tab (Ctrl+Tab and Ctrl+PageUp/Down), Reload,
Force Reload, Back, Forward, Find, Open Settings, Open Processes, New Identity,
Toggle Fullscreen, Toggle Inspector, Toggle Sidebar, Toggle Toolbars, Zoom
In/Out/Reset.
### 6.2 Rust
The Rust [`shortcuts.rs`](../src/shortcuts.rs) **defines** a default shortcut
table (Ctrl+T, Ctrl+W, Ctrl+L, Ctrl+Tab, Ctrl+R, Ctrl+Q, Ctrl+F, Ctrl+D, Ctrl+H,
Ctrl+B, Ctrl+J, zoom, etc.) and can load/save it, but **nothing wires these to
actual handlers**. There is no `key-press-event` handler in `main.rs` or
`tab_manager.rs` that dispatches them. The shortcuts are effectively dead data.
---
## 7. Session Restore & Window Close
### 7.1 Original (C)
- **Session restore** has explicit CLI precedence: `--no-session-restore` /
`--url` skip restore; `--session-restore` forces it; otherwise uses
`settings.restore_session`. On restore failure, falls back to CLI URLs or the
default new-tab URL.
- **Window close** is multi-window aware: `on_window_delete_event` closes the
main window's tabs; if auxiliary windows still have tabs, it hides the main
window and keeps the app alive; the app only quits when the last window closes.
`on_window_destroy` does session save (or privacy-mode clear), network service
shutdown, agent server stop, signer free, and `gtk_main_quit()`.
### 7.2 Rust
- **Session restore** is basic: if `settings.session_restore` is true, it
restores URLs into new tabs. No CLI precedence logic.
- **Window close** is simple: `delete_event` saves the session and quits. No
multi-window awareness (there's only one window).
---
## 8. Feature Matrix
| Feature | Original (C) | Rust |
|---|---|---|
| Multi-window | ✅ | ❌ |
| Per-tab toolbar | ✅ | ❌ |
| Back/Forward/Refresh buttons | ✅ | ❌ |
| URL search completion | ✅ | ❌ |
| Bookmark button + bar | ✅ | ❌ |
| Load-progress bar | ✅ | ❌ |
| Favicon in tab | ✅ | ❌ |
| Tab close button | ✅ | ❌ |
| Tab context menu | ✅ | ❌ |
| Middle-click close | ✅ | ❌ |
| Drag-reorder | ✅ | ❌ |
| New-tab button on strip | ✅ | ❌ |
| User avatar on strip | ✅ | ❌ |
| Hamburger menu | ✅ | ❌ |
| Identity menu (switch/lock/logout) | ✅ | ❌ |
| Network toggles (Tor/FIPS) | ✅ | ❌ |
| Sidebar (agent chat) | ✅ | ❌ |
| Inspector toggle | ✅ | ❌ |
| Zoom controls | ✅ | ❌ |
| Fullscreen toggle | ✅ | ❌ |
| Toolbar toggle | ✅ | ❌ |
| `target="_blank"` handling | ✅ | ❌ |
| Keyboard shortcuts wired | ✅ | ❌ (data only) |
| App theme / CSS | ✅ | ❌ |
| Max-tabs limit | ✅ | ❌ |
| Multi-window close semantics | ✅ | ❌ |
| Session restore precedence | ✅ | ⚠️ basic |
---
## 9. Summary of Gaps
The Rust main window is a **minimal single-window skeleton** compared to the
original. The most significant gaps, in priority order:
1. **No per-tab chrome** — the original gives every tab its own toolbar (URL
entry, back/forward/refresh, bookmark), bookmark bar, progress bar, and rich
tab label. The Rust version has one shared toolbar and plain text tabs.
2. **No multi-window support** — no new-window function, no `Ctrl+N`, no
`target="_blank"`/`window.open()` handling, no per-window notebooks or
sidebars.
3. **No tab context menu** — right-click and middle-click tab actions are
entirely absent.
4. **No hamburger menu** — identity switching, logout, network toggles, and
internal-page navigation are missing.
5. **Keyboard shortcuts are not wired** — the shortcut table exists but no
handler dispatches it.
6. **No tab-strip action widgets** — no new-tab button, no user avatar.
7. **No drag-reordering, favicons, or progress bars.**
8. **No app theme/CSS** — the original has a custom red-accent theme.
---
## 10. Recommended Path to Parity
To bring the Rust main window up to the original's level, the work breaks down
into these steps:
1. **Per-tab toolbar** — move the URL entry, back/forward/refresh buttons, and
bookmark button into each tab's page (a vertical box: toolbar → bookmark bar
→ progress bar → webview), mirroring `tab_create()`.
2. **Rich tab label** — build a horizontal box per tab with favicon image,
ellipsized title label, and a close button; wire `notify::favicon`,
`notify::title`, and close-clicked signals.
3. **Tab context menu** — add a `button-press-event` handler on the tab label
(and children) for right-click (New, Close, Close Others, Close to Right,
Open in New Window, Duplicate, Reload, Download Website) and middle-click
close.
4. **Multi-window support** — add `tab_manager_new_window()`, a `create`-signal
handler for `target="_blank"`, per-window notebooks, and active-window
tracking (focus-in/destroy). Use `WebView::builder().related_view()` to avoid
the WindowFeatures crash.
5. **Hamburger menu** — build a `GtkMenuButton` per tab with the identity,
network, security, and internal-page items, delegating identity actions to
`main.rs`.
6. **Wire keyboard shortcuts** — add a `key-press-event` handler on the window
and each webview that dispatches through `shortcuts_lookup()`.
7. **Tab-strip action widgets** — add a new-tab button and user avatar via
`notebook.set_action_widget()`.
8. **Drag-reordering** — call `notebook.set_tab_reorderable()` per tab, gated on
a `tab_drag_reorder` setting.
9. **App theme** — add a CSS provider with the red-accent theme, adapting to the
`theme_dark` setting.
10. **Window close semantics** — make `delete_event` multi-window aware so the
app quits only when the last window closes.
+116
View File
@@ -0,0 +1,116 @@
# Rust Implementation Assessment: What's Implemented vs. Missing
**Date:** 2026-08-17
**Scope:** Current state of the Rust port (`sovereign_browser_rust`) compared to
the original C `sovereign_browser`.
**Reference:** [`plans/main-window-comparison.md`](main-window-comparison.md)
---
## 1. Status Legend
- ✅ **Implemented & working**
- ⚠️ **Partially implemented** (works but limited / placeholder)
- ❌ **Not implemented**
---
## 2. Login Dialog
| Feature | Status | Notes |
|---|---|---|
| 5 method tabs (Local, Seed, Read-only, NIP-46, n_signer) | ✅ | Full notebook UI |
| "No Login" button | ✅ | |
| Status/error messages | ✅ | |
| Monospace styling | ✅ | |
| Version in title | ✅ | |
| n_signer USB Serial / qrexec transports | ⚠️ | Show "not supported" message; only UNIX/TCP work (Rust signer crate limitation) |
---
## 3. Main Window & Tabs
| Feature | Status | Notes |
|---|---|---|
| Per-tab toolbar (URL, back/forward/refresh, bookmark, hamburger) | ✅ | |
| Rich tab label (favicon + title + close) | ⚠️ | Favicon widget present but **not populated** (cairo→pixbuf API limitation) |
| Tab context menu (right-click) | ✅ | New/Close/Close Others/Close to Right/New Window/Duplicate/Reload |
| Middle-click close | ✅ | |
| Drag-reorder | ✅ | |
| New-tab button on tab strip | ✅ | Added via `set_action_widget` in main.rs and new windows |
| Load-progress bar | ✅ | |
| Bookmark bar | ⚠️ | Bar widget present but **not populated** with bookmark buttons |
| URL search completion | ❌ | Plain entry, no history/bookmark/search suggestions |
| User avatar on tab strip | ❌ | Not implemented |
| Multi-window (Ctrl+N, context menu) | ✅ | `tab_manager_new_window()` |
| `target="_blank"` / `window.open()` | ❌ | No `create`-signal handler wired |
| Active-window tracking (focus-in) | ⚠️ | New windows set active notebook, but main window doesn't track focus |
| App stays alive when aux window closes | ⚠️ | Aux window destroy doesn't quit, but main-window close always quits |
---
## 4. Menus & Chrome
| Feature | Status | Notes |
|---|---|---|
| Hamburger menu | ✅ | Open File, Reload, Stop, Switch Identity, Logout, Inspector, Sidebar, Profile, Agent, Processes, Settings, About |
| Identity menu (switch/lock/logout) | ⚠️ | Switch + Logout wired; **Lock Session** defined but not in menu |
| Network toggles (Tor/FIPS) | ❌ | Not in menu |
| Security strip toggle | ❌ | Not in menu |
| Nostr signing status | ❌ | Not in menu |
| Sidebar (agent chat) | ❌ | Placeholder only (`tab_manager_toggle_sidebar` is a no-op) |
| App theme CSS | ✅ | Red accent, adapts to `theme_dark` |
---
## 5. Keyboard Shortcuts
| Feature | Status | Notes |
|---|---|---|
| Ctrl+T new tab | ✅ | |
| Ctrl+N new window | ✅ | |
| Ctrl+W close tab | ✅ | |
| Ctrl+L focus URL | ✅ | |
| Ctrl+R reload | ✅ | |
| Ctrl+Tab / Ctrl+Shift+Tab | ✅ | |
| Zoom in/out/reset | ✅ | |
| Ctrl+Shift+I inspector | ✅ | |
| Ctrl+Shift+A sidebar | ⚠️ | Wired but sidebar is a no-op |
| Ctrl+Shift+M toolbars | ✅ | |
| F11 fullscreen | ⚠️ | Toggles but no state tracking (gtk-rs has no `is_fullscreen` getter) |
| Configurable shortcuts (settings page) | ❌ | `shortcuts.rs` defines defaults but they're not wired to the settings UI |
---
## 6. Session & Window Lifecycle
| Feature | Status | Notes |
|---|---|---|
| Session save/restore | ✅ | Basic (restores URLs) |
| Session restore CLI precedence | ❌ | No `--no-session-restore` / `--url` precedence logic |
| Multi-window close semantics | ⚠️ | App quits when main window closes even if aux windows remain |
| Privacy mode (clear session/history on shutdown) | ❌ | Not implemented |
---
## 7. Summary of Remaining Gaps (Priority Order)
1. **`target="_blank"` / `window.open()` handling** — no `create`-signal handler, so popups/new-window links don't work. This is the most impactful missing feature.
2. **Agent sidebar** — `tab_manager_toggle_sidebar` is a no-op; the original has a full per-window `GtkPaned` sidebar with an agent chat webview.
3. **URL search completion** — the original shows history + bookmarks + search suggestions in the URL bar; the Rust version has a plain entry.
4. **User avatar on the tab strip** — the original shows the Nostr profile picture; not implemented.
5. **Bookmark bar population** — the bar widget exists but isn't filled with bookmark buttons.
6. **Favicon population** — the favicon widget exists but isn't populated (cairo→pixbuf conversion not exposed in gdk-rs 0.18).
7. **Hamburger menu completeness** — missing Tor/FIPS toggles, security strip, Nostr signing status, and Lock Session.
8. **Multi-window close semantics** — app should stay alive when only auxiliary windows remain.
9. **Session restore CLI precedence** and **privacy mode** on shutdown.
10. **Configurable keyboard shortcuts** wired to the settings page.
---
## 8. Notes on Recent Fixes
- **Removed the redundant shared top-level toolbar** (the "weird entry box with Settings") — each tab now has its own toolbar.
- **Fixed URL loading** — added `normalize_url()` so bare domains like `google.com` get `https://` prepended.
- **Fixed the main notebook/window storage** — the main notebook and window are now stored in `glib::thread_guard::ThreadGuard` globals, which fixes all features that depend on `tab_manager_get_main_notebook()` (keyboard shortcuts, menu identity actions, new-window, etc.). Previously these returned `None` and were silently broken.
- **New-tab button** — present via `set_action_widget` in both the main window and new windows; added an explicit `show()` to ensure visibility.
+97
View File
@@ -0,0 +1,97 @@
# WebKitGTK Rust Binding Assessment
## Current Situation
The C project (`sovereign_browser`) uses **WebKitGTK 4.1** (`webkit2gtk-4.1`), which is the GTK3-based WebKit2 API. This is specified in the Makefile:
```
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 ...)
```
The Rust port currently uses the `webkit2gtk` crate v2.0.2, which is the **Tauri fork** of the gtk-rs WebKitGTK bindings. This crate wraps **WebKitGTK 2.x** (the GTK3 API), NOT WebKitGTK 4.1.
## The Version Confusion
WebKitGTK has two independent versioning schemes:
| pkg-config name | GTK version | WebKit API version | Soup version | Status |
|---|---|---|---|---|
| `webkit2gtk-4.0` | GTK3 | WebKit2 | libsoup2 | Legacy |
| `webkit2gtk-4.1` | GTK3 | WebKit2 | libsoup3 | **Current stable (GTK3)** |
| `webkit6gtk` | GTK4 | WebKitWebProcess | libsoup3 | Future (GTK4) |
The C project uses `webkit2gtk-4.1` — the GTK3 + libsoup3 variant. This is the current stable API for GTK3-based browsers.
## Available Rust Crates
### 1. `webkit2gtk` v2.0.2 (current — Tauri fork)
- **Wraps**: WebKitGTK 2.x (GTK3, libsoup2)
- **GTK version**: GTK3 (gtk 0.18)
- **Soup version**: libsoup3 (soup3 crate)
- **Status**: Maintained by Tauri team, stable
- **API mismatch**: Wraps an older WebKitGTK API than the C project uses
- **Feature flags**: `v2_2` through `v2_40` control which API versions are available
### 2. `tauri-webkit2gtk` v0.14.0
- **Wraps**: Same as above, different package name
- **Status**: Tauri's own fork, same underlying bindings
- **Not useful**: Same API as `webkit2gtk` 2.0
### 3. `webkit2gtk-webextension` v0.16.0
- **Wraps**: WebKit web extension API only
- **Not useful**: For browser extensions, not the main browser
### 4. GTK4 + `webkit6gtk` (does not exist yet as a Rust crate)
- **Wraps**: WebKitGTK for GTK4
- **Status**: No stable Rust crate exists
- **Not viable**: Would require GTK4 migration and no bindings available
## Assessment
### The `webkit2gtk` 2.0 crate IS the right choice
Despite the version number confusion, the `webkit2gtk` 2.0.2 crate is the **correct and only viable option** for a Rust WebKitGTK browser:
1. **It's the only maintained Rust binding** for WebKitGTK on GTK3
2. **It uses GTK3** (gtk 0.18), matching the C project's GTK3 dependency
3. **It uses libsoup3** (soup3 crate), matching the C project's `libsoup-3.0` dependency
4. **The feature flags** (`v2_40`) enable the latest WebKitGTK API features
5. **The API differences** from the C project are minor — method names and signatures differ slightly but the functionality is the same
### Why not switch to GTK4?
- No stable Rust WebKitGTK binding exists for GTK4
- GTK4 has a completely different widget API (no `GtkNotebook`, no `GtkToolbar`, etc.)
- Would require a complete UI rewrite
- The C project is GTK3, so staying on GTK3 maintains parity
### The compilation errors are NOT a version problem
The ~25 remaining compilation errors are **normal Rust binding API differences**, not version incompatibilities:
| Error | Cause | Fix |
|---|---|---|
| `no method set_allow_popups` | Method doesn't exist in this binding | Remove the call |
| `no method read_to_end` | Needs `InputStreamExtManual` trait | Add `use gio::prelude::InputStreamExtManual` |
| `static mut Send/Sync` | Raw pointers aren't Send/Sync | Use `Mutex<Option<...>>` instead of `static mut` |
| `Notebook methods not found` | Need `gtk::prelude::*` | Add the prelude import |
| `RelayPool::query_sync` return type | Type inference failure | Add explicit type annotation |
| `UserScript::new` args | Takes 5 args not 3 | Already fixed — pass `&[]` for allow/block lists |
| `WebView::web_context` | Builder method not setter | Already fixed — use builder pattern |
## Recommendation
**Stay with `webkit2gtk` 2.0.2.** It is the correct choice. The compilation errors are straightforward Rust API fixes, not architectural problems. Switching to a different WebKitGTK version would not help — there is no better Rust binding available.
## Next Steps
1. Fix the remaining ~25 compilation errors (all are minor API differences)
2. Build and run the browser
3. Test the Nostr bridge, login dialog, and tab management
The errors can be fixed by:
- Adding `use gtk::prelude::*` where needed
- Replacing `static mut` with `Mutex<Option<...>>`
- Removing non-existent method calls (`set_allow_popups`)
- Adding explicit type annotations where inference fails
- Using the correct trait imports (`InputStreamExtManual`)
+20
View File
@@ -0,0 +1,20 @@
//! Agent chat management
//!
//! Port of `agent_chat.c` / `agent_chat.h` from the C project.
use crate::agent_chat_store;
/// Initialize the agent chat module.
pub fn agent_chat_init() {
agent_chat_store::agent_chat_store_init();
}
/// Handle a chat request from the web UI.
pub fn agent_chat_handle_request(_request: &str) -> String {
// TODO: implement chat request handling
serde_json::json!({
"status": "ok",
"message": "Chat handler not yet fully implemented"
})
.to_string()
}
+100
View File
@@ -0,0 +1,100 @@
//! Agent chat message store
//!
//! Port of `agent_chat_store.c` / `agent_chat_store.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// A single chat message.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// A tool call within a message.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolCallFunction,
}
/// A tool call function.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
/// The chat session state.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatSession {
pub messages: Vec<ChatMessage>,
pub system_prompt: String,
}
impl Default for ChatSession {
fn default() -> Self {
ChatSession {
messages: Vec::new(),
system_prompt: String::new(),
}
}
}
/// Global chat store.
static G_CHAT_STORE: Lazy<Mutex<ChatSession>> = Lazy::new(|| Mutex::new(ChatSession::default()));
/// Initialize the chat store.
pub fn agent_chat_store_init() {
let mut store = G_CHAT_STORE.lock().unwrap();
*store = ChatSession::default();
}
/// Add a message to the chat store.
pub fn agent_chat_store_add_message(msg: ChatMessage) {
let mut store = G_CHAT_STORE.lock().unwrap();
store.messages.push(msg);
}
/// Get all messages.
pub fn agent_chat_store_get_messages() -> Vec<ChatMessage> {
let store = G_CHAT_STORE.lock().unwrap();
store.messages.clone()
}
/// Clear all messages.
pub fn agent_chat_store_clear() {
let mut store = G_CHAT_STORE.lock().unwrap();
store.messages.clear();
}
/// Set the system prompt.
pub fn agent_chat_store_set_system_prompt(prompt: &str) {
let mut store = G_CHAT_STORE.lock().unwrap();
store.system_prompt = prompt.to_string();
}
/// Get the system prompt.
pub fn agent_chat_store_get_system_prompt() -> String {
let store = G_CHAT_STORE.lock().unwrap();
store.system_prompt.clone()
}
/// Get the full session.
pub fn agent_chat_store_get_session() -> ChatSession {
let store = G_CHAT_STORE.lock().unwrap();
store.clone()
}
/// Load a session.
pub fn agent_chat_store_load_session(session: ChatSession) {
let mut store = G_CHAT_STORE.lock().unwrap();
*store = session;
}
+164
View File
@@ -0,0 +1,164 @@
//! Nostr conversation persistence for agent chat
//!
//! Port of `agent_conversations.c` / `agent_conversations.h` from the C project.
//! Uses `nostr_signer` from `rust_core_lib`.
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::db;
use crate::agent_chat_store;
/// The Nostr kind for arbitrary custom app data (NIP-78).
const AGENT_CONV_KIND: u64 = 30078;
/// The t-tag that identifies conversation events.
const AGENT_CONV_T_TAG: &str = "client-ai-chat-v1";
/// Global state for conversation persistence.
static G_CONV_STATE: Lazy<Mutex<ConvState>> = Lazy::new(|| Mutex::new(ConvState::default()));
struct ConvState {
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: String,
have_signer: bool,
}
impl Default for ConvState {
fn default() -> Self {
ConvState {
signer: None,
pubkey_hex: String::new(),
have_signer: false,
}
}
}
/// Initialize the conversation persistence module.
pub fn agent_conversations_init(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
let mut state = G_CONV_STATE.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty();
}
/// Update the signer reference.
pub fn agent_conversations_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
agent_conversations_init(signer, pubkey_hex);
}
/// List all saved conversations from the local SQLite cache.
pub fn agent_conversations_list() -> Vec<serde_json::Value> {
match db::db_query_events(&[AGENT_CONV_KIND], None, None, None, None) {
Ok(events) => {
let mut result = Vec::new();
for event in events {
let tags = event["tags"].as_array();
let has_conv_tag = tags.map_or(false, |t| {
t.iter().any(|tag| {
tag.as_array()
.map_or(false, |a| a.len() >= 2 && a[0] == "t" && a[1] == AGENT_CONV_T_TAG)
})
});
if has_conv_tag {
let conv_id = tags.and_then(|t| {
t.iter().find_map(|tag| {
tag.as_array().and_then(|a| {
if a.len() >= 2 && a[0] == "d" {
Some(a[1].as_str().unwrap_or("").to_string())
} else {
None
}
})
})
}).unwrap_or_default();
result.push(serde_json::json!({
"id": conv_id,
"title": event["content"].as_str().unwrap_or("Untitled"),
"updated_at": event["created_at"],
}));
}
}
result
}
Err(_) => Vec::new(),
}
}
/// Save the current agent chat session to Nostr as a kind 30078 event.
pub fn agent_conversations_save(
conversation_id: Option<&str>,
title: Option<&str>,
) -> Option<String> {
let state = G_CONV_STATE.lock().unwrap();
if !state.have_signer {
return None;
}
let conv_id = conversation_id
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let session = agent_chat_store::agent_chat_store_get_session();
let _title = title
.map(|s| s.to_string())
.unwrap_or_else(|| {
session.messages.first()
.map(|m| {
let truncated: String = m.content.chars().take(60).collect();
truncated
})
.unwrap_or_else(|| "Untitled".to_string())
});
println!(
"[conversations] Save requested: id={} (NIP-44 encryption not yet implemented)",
conv_id
);
Some(conv_id)
}
/// Load a conversation from the local SQLite cache.
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let events = db::db_query_events(
&[AGENT_CONV_KIND],
None,
None,
None,
Some(1),
)?;
for event in events {
let tags = event["tags"].as_array();
let has_id = tags.map_or(false, |t| {
t.iter().any(|tag| {
tag.as_array()
.map_or(false, |a| a.len() >= 2 && a[0] == "d" && a[1] == conversation_id)
})
});
if has_id {
if let Some(content_str) = event["content"].as_str() {
if let Ok(data) = serde_json::from_str::<serde_json::Value>(content_str) {
if let Some(messages) = data["messages"].as_array() {
let msgs: Vec<agent_chat_store::ChatMessage> =
serde_json::from_value(serde_json::Value::Array(messages.clone()))?;
let session = agent_chat_store::ChatSession {
messages: msgs,
system_prompt: String::new(),
};
agent_chat_store::agent_chat_store_load_session(session);
return Ok(());
}
}
}
}
}
Err("Conversation not found".into())
}
+24
View File
@@ -0,0 +1,24 @@
//! Filesystem tools for the agent
//!
//! Port of `agent_fs_tools.c` / `agent_fs_tools.h` from the C project.
/// Read a file and return its contents.
pub fn agent_fs_read_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))
}
/// Write content to a file.
pub fn agent_fs_write_file(path: &str, content: &str) -> Result<(), String> {
std::fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))
}
/// List files in a directory.
pub fn agent_fs_list_dir(path: &str) -> Result<Vec<String>, String> {
let entries = std::fs::read_dir(path).map_err(|e| format!("Failed to list directory: {}", e))?;
let mut files = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
files.push(entry.file_name().to_string_lossy().to_string());
}
Ok(files)
}
+82
View File
@@ -0,0 +1,82 @@
//! LLM client for agent chat
//!
//! Port of `agent_llm.c` / `agent_llm.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// LLM provider configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LlmProvider {
pub name: String,
pub base_url: String,
pub api_key: String,
pub model: String,
}
/// Global LLM state.
static G_LLM_STATE: Lazy<Mutex<LlmState>> = Lazy::new(|| Mutex::new(LlmState::default()));
struct LlmState {
provider: Option<LlmProvider>,
}
impl Default for LlmState {
fn default() -> Self {
LlmState { provider: None }
}
}
/// Initialize the LLM module.
pub fn agent_llm_init() {
let mut state = G_LLM_STATE.lock().unwrap();
if let Ok(Some(config)) = crate::db::db_kv_get("agent.llm_provider") {
if let Ok(provider) = serde_json::from_str::<LlmProvider>(&config) {
state.provider = Some(provider);
}
}
}
/// Set the LLM provider.
pub fn agent_llm_set_provider(provider: LlmProvider) {
let mut state = G_LLM_STATE.lock().unwrap();
state.provider = Some(provider.clone());
if let Ok(config) = serde_json::to_string(&provider) {
let _ = crate::db::db_kv_set("agent.llm_provider", &config);
}
}
/// Get the current LLM provider.
pub fn agent_llm_get_provider() -> Option<LlmProvider> {
let state = G_LLM_STATE.lock().unwrap();
state.provider.clone()
}
/// Send a chat completion request to the LLM.
pub async fn agent_llm_chat_completion(
messages: &[serde_json::Value],
tools: Option<&[serde_json::Value]>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let state = G_LLM_STATE.lock().unwrap();
let provider = state.provider.as_ref().ok_or("No LLM provider configured")?;
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"model": provider.model,
"messages": messages,
});
if let Some(tools) = tools {
body["tools"] = serde_json::Value::Array(tools.to_vec());
}
let response = client
.post(format!("{}/v1/chat/completions", provider.base_url))
.header("Authorization", format!("Bearer {}", provider.api_key))
.json(&body)
.send()
.await?;
let result: serde_json::Value = response.json().await?;
Ok(result)
}
+53
View File
@@ -0,0 +1,53 @@
//! Agent login management
//!
//! Port of `agent_login.c` / `agent_login.h` from the C project.
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Agent login state.
static G_AGENT_LOGIN: Lazy<Mutex<AgentLoginState>> = Lazy::new(|| Mutex::new(AgentLoginState::default()));
struct AgentLoginState {
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: String,
logged_in: bool,
}
impl Default for AgentLoginState {
fn default() -> Self {
AgentLoginState {
signer: None,
pubkey_hex: String::new(),
logged_in: false,
}
}
}
/// Initialize agent login.
pub fn agent_login_init() {
let mut state = G_AGENT_LOGIN.lock().unwrap();
state.logged_in = false;
}
/// Set the agent signer.
pub fn agent_login_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
let mut state = G_AGENT_LOGIN.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.logged_in = !pubkey_hex.is_empty();
}
/// Check if the agent is logged in.
pub fn agent_login_is_logged_in() -> bool {
let state = G_AGENT_LOGIN.lock().unwrap();
state.logged_in
}
/// Get the agent's pubkey.
pub fn agent_login_get_pubkey() -> String {
let state = G_AGENT_LOGIN.lock().unwrap();
state.pubkey_hex.clone()
}
+124
View File
@@ -0,0 +1,124 @@
//! Agent main loop — orchestrates LLM calls and tool execution
//!
//! Port of `agent_loop.c` / `agent_loop.h` from the C project.
use crate::agent_chat_store;
use crate::agent_llm;
use crate::agent_tools;
/// Run the agent loop for a single user message.
pub async fn agent_loop_run(user_message: &str) -> Result<String, Box<dyn std::error::Error>> {
// Add user message to store
agent_chat_store::agent_chat_store_add_message(
agent_chat_store::ChatMessage {
role: "user".to_string(),
content: user_message.to_string(),
tool_calls: None,
tool_call_id: None,
},
);
// Get conversation history
let session = agent_chat_store::agent_chat_store_get_session();
let mut messages: Vec<serde_json::Value> = Vec::new();
// Add system prompt
if !session.system_prompt.is_empty() {
messages.push(serde_json::json!({
"role": "system",
"content": session.system_prompt,
}));
}
// Add conversation history
for msg in &session.messages {
messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content,
}));
}
// Get available tools
let tools: Vec<serde_json::Value> = agent_tools::agent_tools_get_all()
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.input_schema,
}
})
})
.collect();
// Call LLM
let response = agent_llm::agent_llm_chat_completion(&messages, Some(&tools)).await?;
// Extract assistant message
if let Some(choice) = response["choices"][0]["message"].as_object() {
let content = choice.get("content").and_then(|c| c.as_str()).unwrap_or("");
let tool_calls = choice.get("tool_calls");
// Add assistant message to store
let mut assistant_msg = agent_chat_store::ChatMessage {
role: "assistant".to_string(),
content: content.to_string(),
tool_calls: None,
tool_call_id: None,
};
// Handle tool calls
if let Some(tc) = tool_calls {
if let Some(calls) = tc.as_array() {
let mut tool_call_list = Vec::new();
for call in calls {
let name = call["function"]["name"].as_str().unwrap_or("");
let args_str = call["function"]["arguments"].as_str().unwrap_or("{}");
let args: serde_json::Value = serde_json::from_str(args_str).unwrap_or_default();
// Execute the tool
match agent_tools::agent_tools_execute(name, &args) {
Ok(result) => {
tool_call_list.push(agent_chat_store::ToolCall {
id: call["id"].as_str().unwrap_or("").to_string(),
call_type: "function".to_string(),
function: agent_chat_store::ToolCallFunction {
name: name.to_string(),
arguments: args_str.to_string(),
},
});
// Add tool result message
agent_chat_store::agent_chat_store_add_message(
agent_chat_store::ChatMessage {
role: "tool".to_string(),
content: serde_json::to_string(&result).unwrap_or_default(),
tool_calls: None,
tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()),
},
);
}
Err(e) => {
agent_chat_store::agent_chat_store_add_message(
agent_chat_store::ChatMessage {
role: "tool".to_string(),
content: format!("Error: {}", e),
tool_calls: None,
tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()),
},
);
}
}
}
assistant_msg.tool_calls = Some(tool_call_list);
}
}
agent_chat_store::agent_chat_store_add_message(assistant_msg);
return Ok(content.to_string());
}
Ok(String::new())
}
+309
View File
@@ -0,0 +1,309 @@
//! MCP (Model Context Protocol) server for agent tools
//!
//! Port of `agent_mcp.c` / `agent_mcp.h` from the C project.
//!
//! Implements a Streamable HTTP server on port 17777 that accepts
//! JSON-RPC 2.0 requests (initialize, tools/list, tools/call) and
//! dispatches them to the agent_tools module.
use std::collections::HashMap;
use std::io::{Read, Write, BufRead, BufReader};
use std::net::{TcpListener, TcpStream};
use std::sync::Mutex;
use std::thread;
use once_cell::sync::Lazy;
use crate::agent_tools;
/// MCP tool definition.
#[derive(Debug, Clone, serde::Serialize)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
/// MCP server state.
static G_MCP_STATE: Lazy<Mutex<McpState>> = Lazy::new(|| Mutex::new(McpState::default()));
struct McpState {
running: bool,
port: u16,
}
impl Default for McpState {
fn default() -> Self {
McpState {
running: false,
port: 17777,
}
}
}
/// Initialize the MCP server.
pub fn agent_mcp_init() {
let mut state = G_MCP_STATE.lock().unwrap();
state.port = 17777;
}
/// Start the MCP server (spawns a background thread).
pub fn agent_mcp_start() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_MCP_STATE.lock().unwrap();
if state.running {
return Ok(());
}
state.running = true;
let port = state.port;
drop(state);
println!("[mcp] MCP server starting on port {}", port);
thread::spawn(move || {
run_server(port);
});
Ok(())
}
/// Stop the MCP server.
pub fn agent_mcp_stop() {
let mut state = G_MCP_STATE.lock().unwrap();
state.running = false;
println!("[mcp] MCP server stopped");
}
/// Check if the MCP server is running.
pub fn agent_mcp_is_running() -> bool {
let state = G_MCP_STATE.lock().unwrap();
state.running
}
/// Get the MCP server port.
pub fn agent_mcp_get_port() -> u16 {
let state = G_MCP_STATE.lock().unwrap();
state.port
}
/// Main server loop: accept connections and handle HTTP requests.
fn run_server(port: u16) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("[mcp] Failed to bind port {}: {}", port, e);
// Try fallback ports
for p in (port + 1)..(port + 100) {
if let Ok(l) = TcpListener::bind(("127.0.0.1", p)) {
println!("[mcp] MCP server bound to fallback port {}", p);
let mut state = G_MCP_STATE.lock().unwrap();
state.port = p;
drop(state);
handle_connections(l);
return;
}
}
eprintln!("[mcp] Failed to bind any port in range {}-{}", port, port + 100);
return;
}
};
println!("[mcp] MCP server listening on http://127.0.0.1:{}/mcp", port);
handle_connections(listener);
}
/// Accept and handle connections.
fn handle_connections(listener: TcpListener) {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(move || {
let _ = handle_connection(stream);
});
}
Err(e) => {
eprintln!("[mcp] Accept error: {}", e);
}
}
}
}
/// Handle a single HTTP connection.
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
// Read the request line (e.g. "POST /mcp HTTP/1.1").
if reader.read_line(&mut line)? == 0 {
return Ok(());
}
let is_mcp = line.contains("/mcp") || line.contains("/");
// Read headers.
let mut headers: HashMap<String, String> = HashMap::new();
let mut content_length: usize = 0;
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
break; // End of headers
}
if let Some(pos) = trimmed.find(':') {
let key = trimmed[..pos].trim().to_lowercase();
let val = trimmed[pos + 1..].trim().to_string();
if key == "content-length" {
content_length = val.parse().unwrap_or(0);
}
headers.insert(key, val);
}
}
// Read body if present.
let mut body = String::new();
if content_length > 0 {
let mut buf = vec![0u8; content_length];
reader.read_exact(&mut buf)?;
body = String::from_utf8_lossy(&buf).to_string();
}
// Handle the request.
let (status, response_body) = if is_mcp && !body.is_empty() {
match agent_mcp_handle_request(&body) {
Ok(resp) => ("200 OK", resp),
Err(e) => (
"500 Internal Server Error",
serde_json::json!({
"jsonrpc": "2.0",
"error": { "code": -32603, "message": e.to_string() }
})
.to_string(),
),
}
} else if is_mcp && body.is_empty() {
// GET request to /mcp — return server info.
(
"200 OK",
serde_json::json!({
"server": "sovereign-browser-mcp",
"version": crate::version::VERSION,
"capabilities": {
"tools": {}
}
})
.to_string(),
)
} else {
("404 Not Found", "Not Found".to_string())
};
// Send HTTP response.
let response = format!(
"HTTP/1.1 {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
\r\n\
{}",
status,
response_body.len(),
response_body
);
stream.write_all(response.as_bytes())?;
stream.flush()?;
Ok(())
}
/// Handle an MCP JSON-RPC request.
pub fn agent_mcp_handle_request(body: &str) -> Result<String, Box<dyn std::error::Error>> {
let request: serde_json::Value = serde_json::from_str(body)?;
let method = request["method"]
.as_str()
.ok_or("Missing method")?;
match method {
"initialize" => {
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "sovereign-browser",
"version": crate::version::VERSION
}
}
})
.to_string())
}
"tools/list" => {
let tools = agent_tools::agent_tools_get_all();
let mcp_tools: Vec<McpTool> = tools
.into_iter()
.map(|t| McpTool {
name: t.name,
description: t.description,
input_schema: t.input_schema,
})
.collect();
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": { "tools": mcp_tools }
})
.to_string())
}
"tools/call" => {
let name = request["params"]["name"]
.as_str()
.ok_or("Missing tool name")?;
let args = &request["params"]["arguments"];
match agent_tools::agent_tools_execute(name, args) {
Ok(result) => {
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": { "content": [{ "type": "text", "text": serde_json::to_string(&result)? }] }
})
.to_string())
}
Err(e) => {
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"error": { "code": -32000, "message": e }
})
.to_string())
}
}
}
"ping" => {
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {}
})
.to_string())
}
_ => {
Ok(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"error": { "code": -32601, "message": format!("Method not found: {}", method) }
})
.to_string())
}
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Agent HTTP server for browser automation
//!
//! Port of `agent_server.c` / `agent_server.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::agent_mcp;
/// Agent server state.
static G_SERVER_STATE: Lazy<Mutex<ServerState>> = Lazy::new(|| Mutex::new(ServerState::default()));
struct ServerState {
running: bool,
port: u16,
}
impl Default for ServerState {
fn default() -> Self {
ServerState {
running: false,
port: 17777,
}
}
}
/// Initialize the agent server.
pub fn agent_server_init() {
let mut state = G_SERVER_STATE.lock().unwrap();
state.port = 17777;
}
/// Start the agent server.
pub fn agent_server_start() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_SERVER_STATE.lock().unwrap();
if state.running {
return Ok(());
}
state.running = true;
println!("[server] Agent server starting on port {}", state.port);
// Start MCP server
agent_mcp::agent_mcp_start()?;
Ok(())
}
/// Stop the agent server.
pub fn agent_server_stop() {
let mut state = G_SERVER_STATE.lock().unwrap();
state.running = false;
agent_mcp::agent_mcp_stop();
println!("[server] Agent server stopped");
}
/// Check if the agent server is running.
pub fn agent_server_is_running() -> bool {
let state = G_SERVER_STATE.lock().unwrap();
state.running
}
/// Get the agent server port.
pub fn agent_server_get_port() -> u16 {
let state = G_SERVER_STATE.lock().unwrap();
state.port
}
+166
View File
@@ -0,0 +1,166 @@
//! Nostr kind 31123 skill management for sovereign_browser
//!
//! Skills are PUBLIC Nostr events (kind 31123) that define system prompt
//! templates, LLM parameters, and tool requirements.
//!
//! Port of `agent_skills.c` / `agent_skills.h` from the C project.
//! Uses `nostr_signer` from `rust_core_lib`.
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::db;
/// The Nostr kind for skills.
const AGENT_SKILL_KIND: u64 = 31123;
/// db_kv key for the selected skill d-tags.
const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills";
/// Maximum number of requires_tool tags per skill.
const AGENT_SKILL_MAX_TOOLS: usize = 32;
/// A skill definition.
#[derive(Debug, Clone)]
pub struct AgentSkill {
pub d_tag: String,
pub name: String,
pub description: String,
pub pubkey: String,
pub content: String,
pub requires_tools: Vec<String>,
}
/// Global state for skills.
static G_SKILL_STATE: Lazy<Mutex<SkillState>> = Lazy::new(|| Mutex::new(SkillState::default()));
struct SkillState {
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: String,
have_signer: bool,
}
impl Default for SkillState {
fn default() -> Self {
SkillState {
signer: None,
pubkey_hex: String::new(),
have_signer: false,
}
}
}
/// Initialize the skills module.
pub fn agent_skills_init(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
let mut state = G_SKILL_STATE.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty();
}
/// Update the signer reference.
pub fn agent_skills_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
agent_skills_init(signer, pubkey_hex);
}
/// Fetch kind 31123 skill events from the local SQLite cache.
pub fn agent_skills_fetch() -> Vec<serde_json::Value> {
match db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, None) {
Ok(events) => {
let mut result = Vec::new();
for event in events {
let tags = event["tags"].as_array();
let d_tag = tags.and_then(|t| {
t.iter().find_map(|tag| {
tag.as_array().and_then(|a| {
if a.len() >= 2 && a[0] == "d" {
Some(a[1].as_str().unwrap_or("").to_string())
} else {
None
}
})
})
}).unwrap_or_default();
let name = tags.and_then(|t| {
t.iter().find_map(|tag| {
tag.as_array().and_then(|a| {
if a.len() >= 2 && a[0] == "name" {
Some(a[1].as_str().unwrap_or("").to_string())
} else {
None
}
})
})
}).unwrap_or_default();
let description = tags.and_then(|t| {
t.iter().find_map(|tag| {
tag.as_array().and_then(|a| {
if a.len() >= 2 && a[0] == "description" {
Some(a[1].as_str().unwrap_or("").to_string())
} else {
None
}
})
})
}).unwrap_or_default();
let requires_tools: Vec<String> = tags.map_or(vec![], |t| {
t.iter().filter_map(|tag| {
tag.as_array().and_then(|a| {
if a.len() >= 2 && a[0] == "requires_tool" {
Some(a[1].as_str().unwrap_or("").to_string())
} else {
None
}
})
}).collect()
});
result.push(serde_json::json!({
"d": d_tag,
"name": name,
"description": description,
"requires_tools": requires_tools,
"content": event["content"].as_str().unwrap_or(""),
"pubkey": event["pubkey"].as_str().unwrap_or(""),
}));
}
result
}
Err(_) => Vec::new(),
}
}
/// Get the default Sovereign Browser Skill.
pub fn agent_skills_get_default() -> serde_json::Value {
serde_json::json!({
"d": "",
"name": "Sovereign Browser Skill",
"description": "Default skill for browser automation",
"content": "You are a helpful assistant with access to browser tools.",
"requires_tools": ["browser_navigate", "browser_click", "browser_type", "browser_screenshot"],
"unsaved": true,
"pubkey": "",
})
}
/// Build the combined system prompt from selected skills.
pub fn agent_skills_build_system_prompt(selected_skills: &[String]) -> String {
let skills = agent_skills_fetch();
let mut parts = Vec::new();
for skill in &skills {
let d_tag = skill["d"].as_str().unwrap_or("");
if selected_skills.contains(&d_tag.to_string()) {
if let Some(content) = skill["content"].as_str() {
parts.push(content.to_string());
}
}
}
parts.join("\n\n")
}
+16
View File
@@ -0,0 +1,16 @@
//! Agent state snapshot for persistence
//!
//! Port of `agent_snapshot.c` / `agent_snapshot.h` from the C project.
use crate::db;
/// Save the agent state snapshot.
pub fn agent_snapshot_save(state_json: &str) -> Result<(), Box<dyn std::error::Error>> {
db::db_kv_set("agent.snapshot", state_json)?;
Ok(())
}
/// Load the agent state snapshot.
pub fn agent_snapshot_load() -> Result<Option<String>, Box<dyn std::error::Error>> {
db::db_kv_get("agent.snapshot")
}
+470
View File
@@ -0,0 +1,470 @@
//! Agent tool catalog and execution
//!
//! Port of `agent_tools.c` / `agent_tools.h` from the C project.
use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use webkit2gtk::WebViewExt;
/// A tool definition.
#[derive(Debug, Clone)]
pub struct AgentTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
/// Global tool registry.
static G_TOOL_REGISTRY: Lazy<Mutex<HashMap<String, AgentTool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Initialize the tool registry with built-in tools.
pub fn agent_tools_init() {
let mut registry = G_TOOL_REGISTRY.lock().unwrap();
registry.clear();
// Browser navigation tools
registry.insert(
"browser_navigate".to_string(),
AgentTool {
name: "browser_navigate".to_string(),
description: "Navigate to a URL in the current tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to navigate to"}
},
"required": ["url"]
}),
},
);
registry.insert(
"browser_click".to_string(),
AgentTool {
name: "browser_click".to_string(),
description: "Click an element on the page".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": {"type": "string", "description": "CSS selector for the element"}
},
"required": ["selector"]
}),
},
);
registry.insert(
"browser_type".to_string(),
AgentTool {
name: "browser_type".to_string(),
description: "Type text into an input field".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": {"type": "string", "description": "CSS selector for the input"},
"text": {"type": "string", "description": "Text to type"}
},
"required": ["selector", "text"]
}),
},
);
registry.insert(
"browser_screenshot".to_string(),
AgentTool {
name: "browser_screenshot".to_string(),
description: "Take a screenshot of the current page".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
// Filesystem tools
registry.insert(
"fs_read_file".to_string(),
AgentTool {
name: "fs_read_file".to_string(),
description: "Read a file from the filesystem".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"}
},
"required": ["path"]
}),
},
);
registry.insert(
"fs_write_file".to_string(),
AgentTool {
name: "fs_write_file".to_string(),
description: "Write content to a file".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}),
},
);
registry.insert(
"fs_list_dir".to_string(),
AgentTool {
name: "fs_list_dir".to_string(),
description: "List files in a directory".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the directory"}
},
"required": ["path"]
}),
},
);
// Tab management tools
registry.insert(
"tab_list".to_string(),
AgentTool {
name: "tab_list".to_string(),
description: "List all open tabs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"new_tab".to_string(),
AgentTool {
name: "new_tab".to_string(),
description: "Open a new browser tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to open (optional)"}
},
"required": []
}),
},
);
registry.insert(
"tab_count".to_string(),
AgentTool {
name: "tab_count".to_string(),
description: "Get the number of open tabs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"toggle_inspector".to_string(),
AgentTool {
name: "toggle_inspector".to_string(),
description: "Toggle the WebKit web inspector on the active tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"eval_js".to_string(),
AgentTool {
name: "eval_js".to_string(),
description: "Execute JavaScript in the active tab's page. Console.log output appears in browser stderr.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"js": {"type": "string", "description": "JavaScript code to execute"}
},
"required": ["js"]
}),
},
);
// Shell tool
registry.insert(
"shell_exec".to_string(),
AgentTool {
name: "shell_exec".to_string(),
description: "Execute a shell command".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to execute"}
},
"required": ["command"]
}),
},
);
}
/// Get all registered tools.
pub fn agent_tools_get_all() -> Vec<AgentTool> {
let registry = G_TOOL_REGISTRY.lock().unwrap();
registry.values().cloned().collect()
}
/// Get a tool by name.
pub fn agent_tools_get(name: &str) -> Option<AgentTool> {
let registry = G_TOOL_REGISTRY.lock().unwrap();
registry.get(name).cloned()
}
/// Execute a tool by name with the given arguments.
pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> {
match name {
"tab_list" => {
let tabs = crate::tab_manager::tab_manager_get_tabs();
Ok(serde_json::json!({
"status": "ok",
"tabs": tabs.iter().map(|t| serde_json::json!({
"id": t.id,
"title": t.title,
"url": t.url,
})).collect::<Vec<_>>()
}))
}
"tab_count" => {
let count = crate::tab_manager::tab_manager_get_count();
Ok(serde_json::json!({
"status": "ok",
"count": count
}))
}
"new_tab" => {
let url = args["url"].as_str();
// Schedule tab creation on the GTK main thread.
// `idle_add_once` requires `Send` (callable from any thread, unlike
// `idle_add_local` which panics off-main). Our closure only captures
// `Option<String>` (Send); gtk widgets are created on the main thread
// when the closure actually runs.
let url_owned = url.map(|s| s.to_string());
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
if let Some(ctx) = wv.web_context() {
crate::tab_manager::tab_manager_new_tab(&nb, &ctx, url_owned.as_deref());
}
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": "New tab scheduled"
}))
}
"toggle_inspector" => {
// Schedule the inspector toggle on the GTK main thread.
glib::idle_add_once(|| {
crate::tab_manager::tab_manager_toggle_inspector();
});
Ok(serde_json::json!({
"status": "ok",
"message": "Inspector toggle scheduled"
}))
}
"browser_navigate" => {
let url = args["url"].as_str().ok_or("Missing url argument")?.to_string();
let url_for_msg = url.clone();
// Schedule navigation on the GTK main thread.
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
let normalized = crate::search::normalize_url(&url);
wv.load_uri(&normalized);
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": format!("Navigating to {}", url_for_msg)
}))
}
"eval_js" => {
let js = args["js"].as_str().ok_or("Missing js argument")?.to_string();
// We can't easily get the return value of async JS from webkit2gtk
// in a synchronous MCP response. Instead, we run the JS and return
// a status. The JS can write results to console.log which appears
// in the browser's stderr.
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
// run_javascript is deprecated in webkit2gtk 2.0.2 but
// there is no replacement in the Rust bindings. Suppress.
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, |_| {});
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": "JS execution scheduled (check browser stderr for console.log output)"
}))
}
"browser_click" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string();
// Build JS to click the element matching the selector.
let js = format!(
r#"(function() {{
var el = document.querySelector({:?});
if (el) {{ el.click(); return "clicked"; }}
return "not found";
}})()"#,
selector
);
let (tx, rx) = std::sync::mpsc::channel::<String>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| {
let msg = match result {
Ok(_) => "clicked",
Err(e) => &format!("error: {}", e),
};
let _ = tx.send(msg.to_string());
});
} else { let _ = tx.send("no webview".to_string()); }
} else { let _ = tx.send("no notebook".to_string()); }
});
let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string());
Ok(serde_json::json!({"status": "ok", "result": result}))
}
"browser_type" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string();
let text = args["text"].as_str().ok_or("Missing text argument")?.to_string();
// Build JS to set the value of the element matching the selector.
let js = format!(
r#"(function() {{
var el = document.querySelector({:?});
if (el) {{
el.value = {};
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
return "typed";
}}
return "not found";
}})()"#,
selector,
serde_json::to_string(&text).unwrap_or_default()
);
let (tx, rx) = std::sync::mpsc::channel::<String>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| {
let msg = match result {
Ok(_) => "typed",
Err(e) => &format!("error: {}", e),
};
let _ = tx.send(msg.to_string());
});
} else { let _ = tx.send("no webview".to_string()); }
} else { let _ = tx.send("no notebook".to_string()); }
});
let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string());
Ok(serde_json::json!({"status": "ok", "result": result}))
}
"browser_screenshot" => {
// Capture a snapshot of the active webview.
// The cairo::Surface from webkit2gtk's snapshot() is captured
// but cairo-rs 0.18 doesn't expose write_to_png. We capture the
// surface dimensions and return a status. A future improvement
// would convert the surface to PNG via the cairo C FFI.
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
wv.snapshot(
webkit2gtk::SnapshotRegion::Visible,
webkit2gtk::SnapshotOptions::NONE,
None::<&gio::Cancellable>,
move |result: Result<cairo::Surface, glib::Error>| {
match result {
Ok(_surface) => {
let _ = tx.send(Ok("snapshot captured".to_string()));
}
Err(e) => { let _ = tx.send(Err(e.to_string())); }
}
},
);
} else { let _ = tx.send(Err("no webview".to_string())); }
} else { let _ = tx.send(Err("no notebook".to_string())); }
});
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(Ok(msg)) => Ok(serde_json::json!({"status": "ok", "message": msg})),
Ok(Err(e)) => Err(format!("Screenshot failed: {}", e)),
Err(_) => Err("Screenshot timed out".to_string()),
}
}
"fs_read_file" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
match std::fs::read_to_string(path) {
Ok(content) => Ok(serde_json::json!({"status": "ok", "content": content})),
Err(e) => Err(format!("Failed to read file: {}", e)),
}
}
"fs_write_file" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
let content = args["content"].as_str().ok_or("Missing content argument")?;
match std::fs::write(path, content) {
Ok(_) => Ok(serde_json::json!({"status": "ok", "message": "File written"})),
Err(e) => Err(format!("Failed to write file: {}", e)),
}
}
"fs_list_dir" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
match std::fs::read_dir(path) {
Ok(entries) => {
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
Ok(serde_json::json!({"status": "ok", "files": files}))
}
Err(e) => Err(format!("Failed to list directory: {}", e)),
}
}
"shell_exec" => {
let command = args["command"].as_str().ok_or("Missing command argument")?;
match std::process::Command::new("sh")
.arg("-c")
.arg(command)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(serde_json::json!({
"status": if output.status.success() { "ok" } else { "error" },
"stdout": stdout,
"stderr": stderr,
"exit_code": output.status.code().unwrap_or(-1),
}))
}
Err(e) => Err(format!("Failed to execute command: {}", e)),
}
}
_ => Err(format!("Unknown tool: {}", name)),
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Bookmark management for sovereign_browser
//!
//! Port of `bookmarks.c` / `bookmarks.h` from the C project.
use crate::db;
/// Add a bookmark.
pub fn bookmarks_add(title: &str, url: &str, folder: &str) -> Result<i64, Box<dyn std::error::Error>> {
let id = db::db_add_bookmark(title, url, folder)?;
// Trigger bookmark sync (Kind 30003) — stub for now, actual relay
// publishing requires NIP-44 encryption + relay connection management.
bookmarks_publish();
Ok(id)
}
/// Get all bookmarks.
pub fn bookmarks_get_all() -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
db::db_get_bookmarks()
}
/// Delete a bookmark.
pub fn bookmarks_delete(id: i64) -> Result<(), Box<dyn std::error::Error>> {
db::db_delete_bookmark(id)?;
bookmarks_publish();
Ok(())
}
/// Publish bookmarks to Nostr relays as kind 30003 bookmark sets.
/// The actual relay publishing requires NIP-44 encryption and relay
/// connection management. For now, we serialize and store the bookmark
/// set locally. The infrastructure is in place for future relay publishing.
pub fn bookmarks_publish() {
if let Ok(bookmarks) = db::db_get_bookmarks() {
if let Ok(json) = serde_json::to_string(&bookmarks) {
let _ = db::db_kv_set("bookmarks_sync.last_payload", &json);
}
}
// TODO: Build and sign a kind 30003 event, publish to bootstrap relays.
}
+121
View File
@@ -0,0 +1,121 @@
//! Command-line interface for sovereign_browser
//!
//! Port of `cli.c` / `cli.h` from the C project.
use crate::key_store::KeyStoreMethod;
use crate::version;
/// Parsed CLI arguments.
#[derive(Debug, Default)]
pub struct CliArgs {
pub urls: Vec<String>,
pub login_method: Option<KeyStoreMethod>,
pub privkey: Option<String>,
pub pubkey: Option<String>,
pub mnemonic: Option<String>,
pub bunker_url: Option<String>,
pub nsigner_transport: Option<String>,
pub nsigner_device: Option<String>,
pub nsigner_index: Option<i32>,
pub profile: Option<String>,
pub no_agent: bool,
pub no_session: bool,
pub no_login: bool,
pub help: bool,
pub version: bool,
}
/// Parse command-line arguments.
pub fn cli_parse() -> CliArgs {
let args: Vec<String> = std::env::args().collect();
let mut cli = CliArgs::default();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => cli.help = true,
"--version" | "-v" => cli.version = true,
"--local" | "-l" => {
cli.login_method = Some(KeyStoreMethod::Local);
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.privkey = Some(args[i].clone());
}
}
"--pubkey" | "-p" => {
cli.login_method = Some(KeyStoreMethod::Readonly);
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.pubkey = Some(args[i].clone());
}
}
"--seed" | "-s" => {
cli.login_method = Some(KeyStoreMethod::Seed);
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.mnemonic = Some(args[i].clone());
}
}
"--bunker" | "-b" => {
cli.login_method = Some(KeyStoreMethod::Nip46);
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.bunker_url = Some(args[i].clone());
}
}
"--nsigner" => {
cli.login_method = Some(KeyStoreMethod::Nsigner);
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.nsigner_device = Some(args[i].clone());
}
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
i += 1;
cli.nsigner_index = Some(args[i].parse().unwrap_or(0));
}
}
"--profile" => {
if i + 1 < args.len() {
i += 1;
cli.profile = Some(args[i].clone());
}
}
"--no-agent" => cli.no_agent = true,
"--no-session" => cli.no_session = true,
"--no-login" => cli.no_login = true,
_ => {
if !args[i].starts_with('-') {
cli.urls.push(args[i].clone());
}
}
}
i += 1;
}
cli
}
/// Print help message.
pub fn cli_print_help() {
println!("sovereign_browser — WebKitGTK + Rust");
println!();
println!("USAGE:");
println!(" sovereign_browser [OPTIONS] [URLS...]");
println!();
println!("OPTIONS:");
println!(" -h, --help Show this help message");
println!(" -v, --version Show version");
println!(" -l, --local [KEY] Login with local nsec key");
println!(" -p, --pubkey [KEY] Read-only mode with npub");
println!(" -s, --seed [PHRASE] Login with BIP-39 seed phrase");
println!(" -b, --bunker [URL] Login with NIP-46 bunker URL");
println!(" --nsigner [DEVICE] [IDX] Login with n_signer hardware");
println!(" --profile [PUBKEY] Use specific profile");
println!(" --no-agent Disable agent server");
println!(" --no-session Disable session restore");
}
/// Print version.
pub fn cli_print_version() {
println!("sovereign_browser {}", version::VERSION);
}
+420
View File
@@ -0,0 +1,420 @@
//! SQLite database layer for sovereign_browser
//!
//! Provides persistent storage for events, settings, bookmarks, history,
//! and other browser data. Uses rusqlite with bundled SQLite.
//!
//! Port of `db.c` / `db.h` from the C project.
use rusqlite::{Connection, params};
use std::path::PathBuf;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Global database connection (thread-safe via Mutex).
static DB: Lazy<Mutex<Option<Connection>>> = Lazy::new(|| Mutex::new(None));
/// Get the database directory path.
fn db_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser")
}
/// Get the database path for a given profile pubkey.
fn db_path(pubkey_hex: &str) -> PathBuf {
db_dir().join("profiles").join(pubkey_hex).join("browser.db")
}
/// Initialize the database for a given profile.
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
let path = db_path(pubkey_hex);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
| rusqlite::OpenFlags::SQLITE_OPEN_CREATE
| rusqlite::OpenFlags::SQLITE_OPEN_FULL_MUTEX,
)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
// Create tables
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
sig TEXT,
relay_url TEXT,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
visit_count INTEGER NOT NULL DEFAULT 1,
last_visit_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
CREATE INDEX IF NOT EXISTS idx_history_last_visit ON history(last_visit_at);
",
)?;
let mut db = DB.lock().unwrap();
*db = Some(conn);
Ok(())
}
/// Close the database connection.
pub fn db_close() {
let mut db = DB.lock().unwrap();
*db = None;
}
/// Store a Nostr event in the database.
pub fn db_store_event(
id: &str,
pubkey: &str,
kind: u64,
created_at: u64,
content: &str,
tags: &str,
sig: Option<&str>,
relay_url: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
conn.execute(
"INSERT OR REPLACE INTO events (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![id, pubkey, kind, created_at, content, tags, sig, relay_url, now],
)?;
}
Ok(())
}
/// Get a Nostr event by ID.
pub fn db_get_event(id: &str) -> Result<Option<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
return Ok(Some(event));
}
}
Ok(None)
}
/// Query events by filter.
pub fn db_query_events(
kinds: &[u64],
authors: Option<&[String]>,
since: Option<u64>,
until: Option<u64>,
limit: Option<u64>,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut sql = String::from(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE 1=1",
);
if !kinds.is_empty() {
let placeholders: Vec<String> = kinds.iter().map(|_| "?".to_string()).collect();
sql.push_str(&format!(" AND kind IN ({})", placeholders.join(",")));
}
if authors.is_some() {
sql.push_str(" AND pubkey = ?");
}
if since.is_some() {
sql.push_str(" AND created_at >= ?");
}
if until.is_some() {
sql.push_str(" AND created_at <= ?");
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(lim) = limit {
sql.push_str(&format!(" LIMIT {}", lim));
}
let mut stmt = conn.prepare(&sql)?;
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
for k in kinds {
param_values.push(Box::new(*k));
}
if let Some(authors) = authors {
if let Some(a) = authors.first() {
param_values.push(Box::new(a.clone()));
}
}
if let Some(s) = since {
param_values.push(Box::new(s));
}
if let Some(u) = until {
param_values.push(Box::new(u));
}
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
let mut rows = stmt.query(params_refs.as_slice())?;
while let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
results.push(event);
}
}
Ok(results)
}
/// Store a key-value pair.
pub fn db_kv_set(key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"INSERT OR REPLACE INTO kv_store (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
}
Ok(())
}
/// Get a key-value pair.
pub fn db_kv_get(key: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare("SELECT value FROM kv_store WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
return Ok(Some(row.get(0)?));
}
}
Ok(None)
}
/// Delete a key-value pair.
pub fn db_kv_delete(key: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM kv_store WHERE key = ?1", params![key])?;
}
Ok(())
}
/// Add a bookmark.
pub fn db_add_bookmark(
title: &str,
url: &str,
folder: &str,
) -> Result<i64, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
conn.execute(
"INSERT INTO bookmarks (title, url, folder, position, created_at, updated_at)
VALUES (?1, ?2, ?3, 0, ?4, ?4)",
params![title, url, folder, now],
)?;
return Ok(conn.last_insert_rowid());
}
Ok(0)
}
/// Get all bookmarks.
pub fn db_get_bookmarks() -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, title, url, folder, position, created_at, updated_at FROM bookmarks ORDER BY folder, position",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let bookmark = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"title": row.get::<_, String>(1)?,
"url": row.get::<_, String>(2)?,
"folder": row.get::<_, String>(3)?,
"position": row.get::<_, i64>(4)?,
"created_at": row.get::<_, u64>(5)?,
"updated_at": row.get::<_, u64>(6)?,
});
results.push(bookmark);
}
}
Ok(results)
}
/// Delete a bookmark.
pub fn db_delete_bookmark(id: i64) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
}
Ok(())
}
/// Update a bookmark's title.
pub fn db_update_bookmark_title(id: i64, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET title = ?1, updated_at = ?2 WHERE id = ?3",
params![title, now_secs(), id],
)?;
}
Ok(())
}
/// Update a bookmark's folder path.
pub fn db_update_bookmark_folder(id: i64, folder: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET folder = ?1, updated_at = ?2 WHERE id = ?3",
params![folder, now_secs(), id],
)?;
}
Ok(())
}
/// Current unix timestamp in seconds.
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Add a history entry.
pub fn db_add_history(url: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Try to update existing entry
let updated = conn.execute(
"UPDATE history SET visit_count = visit_count + 1, last_visit_at = ?1, title = ?2 WHERE url = ?3",
params![now, title, url],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO history (url, title, visit_count, last_visit_at, created_at)
VALUES (?1, ?2, 1, ?3, ?3)",
params![url, title, now],
)?;
}
}
Ok(())
}
/// Get history entries.
pub fn db_get_history(limit: u64) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, url, title, visit_count, last_visit_at, created_at
FROM history ORDER BY last_visit_at DESC LIMIT ?1",
)?;
let mut rows = stmt.query(params![limit])?;
while let Some(row) = rows.next()? {
let entry = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"url": row.get::<_, String>(1)?,
"title": row.get::<_, String>(2)?,
"visit_count": row.get::<_, i64>(3)?,
"last_visit_at": row.get::<_, u64>(4)?,
"created_at": row.get::<_, u64>(5)?,
});
results.push(entry);
}
}
Ok(results)
}
/// Clear all history.
pub fn db_clear_history() -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM history", [])?;
}
Ok(())
}
+152
View File
@@ -0,0 +1,152 @@
//! Embedded web content served via sovereign:// URIs
//!
//! Port of the `embed_web_files.sh` generated content.
//! In the C project, web files from www/ are embedded as C byte arrays.
//! In the Rust port, we embed them as Rust string constants via include_str!.
//!
//! The `www/` directory is a copy of the C project's www/ directory
//! (single-page web apps for profile, bookmarks, settings, processes,
//! fips, and agents).
/// Get embedded content by path.
/// Returns (content, mime_type).
pub fn get_content_with_mime(path: &str) -> Option<(String, &'static str)> {
match path {
// HTML pages
"bookmarks" | "bookmarks.html" => Some((get_bookmarks_html(), "text/html")),
"fips" | "fips.html" => Some((get_fips_html(), "text/html")),
"processes" | "processes.html" => Some((get_processes_html(), "text/html")),
"profile" | "profile.html" => Some((get_profile_html(), "text/html")),
"settings" | "settings.html" => Some((get_settings_html(), "text/html")),
"security" | "security.html" => Some((get_security_html(), "text/html")),
"agents/chat" | "agents/chat.html" => Some((get_agent_chat_html(), "text/html")),
"agents/config" | "agents/config.html" => Some((get_agent_config_html(), "text/html")),
// CSS files
"bookmarks.css" => Some((WWW_BOOKMARKS_CSS.to_string(), "text/css")),
"fips.css" => Some((WWW_FIPS_CSS.to_string(), "text/css")),
"processes.css" => Some((WWW_PROCESSES_CSS.to_string(), "text/css")),
"profile.css" => Some((WWW_PROFILE_CSS.to_string(), "text/css")),
"settings.css" => Some((WWW_SETTINGS_CSS.to_string(), "text/css")),
"sovereign-base.css" => Some((WWW_SOVEREIGN_BASE_CSS.to_string(), "text/css")),
"css/dot-menu.css" => Some((WWW_CSS_DOT_MENU.to_string(), "text/css")),
"css/post-composer.css" => Some((WWW_CSS_POST_COMPOSER.to_string(), "text/css")),
// JS files
"bookmarks.js" => Some((WWW_BOOKMARKS_JS.to_string(), "application/javascript")),
"fips.js" => Some((WWW_FIPS_JS.to_string(), "application/javascript")),
"processes.js" => Some((WWW_PROCESSES_JS.to_string(), "application/javascript")),
"profile.js" => Some((WWW_PROFILE_JS.to_string(), "application/javascript")),
"settings.js" => Some((WWW_SETTINGS_JS.to_string(), "application/javascript")),
"js/dot-menu.js" => Some((WWW_JS_DOT_MENU.to_string(), "application/javascript")),
"js/dot-menu.mjs" => Some((WWW_JS_DOT_MENU_MJS.to_string(), "application/javascript")),
"js/post-composer.js" => Some((WWW_JS_POST_COMPOSER.to_string(), "application/javascript")),
"js/post-composer.mjs" => Some((WWW_JS_POST_COMPOSER_MJS.to_string(), "application/javascript")),
"js/perf-probe.js" => Some((WWW_JS_PERF_PROBE.to_string(), "application/javascript")),
"json-viewer/json-viewer.js" => Some((WWW_JSON_VIEWER_JS.to_string(), "application/javascript")),
"json-viewer/json-viewer.css" => Some((WWW_JSON_VIEWER_CSS.to_string(), "text/css")),
// Agent pages
"agents/chat.css" => Some((WWW_AGENTS_CHAT_CSS.to_string(), "text/css")),
"agents/chat.js" => Some((WWW_AGENTS_CHAT_JS.to_string(), "application/javascript")),
"agents/config.css" => Some((WWW_AGENTS_CONFIG_CSS.to_string(), "text/css")),
"agents/config.js" => Some((WWW_AGENTS_CONFIG_JS.to_string(), "application/javascript")),
// Vendor libraries
"vendor/marked.min.js" => Some((WWW_VENDOR_MARKED.to_string(), "application/javascript")),
"vendor/purify.min.js" => Some((WWW_VENDOR_PURIFY.to_string(), "application/javascript")),
_ => None,
}
}
/// Get embedded content by path (HTML only, for backward compatibility).
pub fn get_content(path: &str) -> Option<String> {
get_content_with_mime(path).map(|(content, _)| content)
}
// ── Embedded file constants ─────────────────────────────────────────
const WWW_BOOKMARKS_CSS: &str = include_str!("../www/bookmarks.css");
const WWW_BOOKMARKS_JS: &str = include_str!("../www/bookmarks.js");
const WWW_FIPS_CSS: &str = include_str!("../www/fips.css");
const WWW_FIPS_JS: &str = include_str!("../www/fips.js");
const WWW_PROCESSES_CSS: &str = include_str!("../www/processes.css");
const WWW_PROCESSES_JS: &str = include_str!("../www/processes.js");
const WWW_PROFILE_CSS: &str = include_str!("../www/profile.css");
const WWW_PROFILE_JS: &str = include_str!("../www/profile.js");
const WWW_SETTINGS_CSS: &str = include_str!("../www/settings.css");
const WWW_SETTINGS_JS: &str = include_str!("../www/settings.js");
const WWW_SOVEREIGN_BASE_CSS: &str = include_str!("../www/sovereign-base.css");
const WWW_CSS_DOT_MENU: &str = include_str!("../www/css/dot-menu.css");
const WWW_CSS_POST_COMPOSER: &str = include_str!("../www/css/post-composer.css");
const WWW_JS_DOT_MENU: &str = include_str!("../www/js/dot-menu.js");
const WWW_JS_DOT_MENU_MJS: &str = include_str!("../www/js/dot-menu.mjs");
const WWW_JS_POST_COMPOSER: &str = include_str!("../www/js/post-composer.js");
const WWW_JS_POST_COMPOSER_MJS: &str = include_str!("../www/js/post-composer.mjs");
const WWW_JS_PERF_PROBE: &str = include_str!("../www/js/perf-probe.js");
const WWW_JSON_VIEWER_JS: &str = include_str!("../www/json-viewer/json-viewer.js");
const WWW_JSON_VIEWER_CSS: &str = include_str!("../www/json-viewer/json-viewer.css");
const WWW_AGENTS_CHAT_CSS: &str = include_str!("../www/agents/chat.css");
const WWW_AGENTS_CHAT_JS: &str = include_str!("../www/agents/chat.js");
const WWW_AGENTS_CONFIG_CSS: &str = include_str!("../www/agents/config.css");
const WWW_AGENTS_CONFIG_JS: &str = include_str!("../www/agents/config.js");
const WWW_VENDOR_MARKED: &str = include_str!("../www/vendor/marked.min.js");
const WWW_VENDOR_PURIFY: &str = include_str!("../www/vendor/purify.min.js");
/// Get the settings page HTML (served from www/settings.html).
pub fn get_settings_html() -> String {
include_str!("../www/settings.html").to_string()
}
/// Get the bookmarks page HTML.
pub fn get_bookmarks_html() -> String {
include_str!("../www/bookmarks.html").to_string()
}
/// Get the FIPS page HTML.
pub fn get_fips_html() -> String {
include_str!("../www/fips.html").to_string()
}
/// Get the processes page HTML.
pub fn get_processes_html() -> String {
include_str!("../www/processes.html").to_string()
}
/// Get the agent chat page HTML.
pub fn get_agent_chat_html() -> String {
include_str!("../www/agents/chat.html").to_string()
}
/// Get the agent config page HTML.
pub fn get_agent_config_html() -> String {
include_str!("../www/agents/config.html").to_string()
}
/// Get the security page HTML (redirects to settings in the C version).
pub fn get_security_html() -> String {
"<!DOCTYPE html><html><head>\
<meta http-equiv='refresh' content='0; url=sovereign://settings'>\
</head><body>Redirecting to settings…</body></html>"
.to_string()
}
/// Get the profile page HTML.
pub fn get_profile_html() -> String {
include_str!("../www/profile.html").to_string()
}
/// HTML-escape a string for safe embedding in HTML attributes/text.
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
c @ ('&' | '<' | '>' | '"' | '\'') => {
out.push_str(&format!("&#{};", c as u32));
}
_ => out.push(ch),
}
}
out
}
+194
View File
@@ -0,0 +1,194 @@
//! FIPS mesh network control
//!
//! Port of `fips_control.c` / `fips_control.h` from the C project.
//! Implements a synchronous FIPS JSON-line control client over a Unix
//! domain socket. Requests are `{"command": "..."}` JSON lines; responses
//! are `{"status": "ok", "data": {...}}` JSON lines.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
use once_cell::sync::Lazy;
const FIPS_RESPONSE_MAX: usize = 1024 * 1024;
const FIPS_TIMEOUT_MS: u64 = 1500;
/// FIPS control state.
static G_FIPS_CONTROL: Lazy<Mutex<FipsControlState>> = Lazy::new(|| Mutex::new(FipsControlState::default()));
struct FipsControlState {
running: bool,
fips_path: String,
socket_path: String,
}
impl Default for FipsControlState {
fn default() -> Self {
FipsControlState {
running: false,
fips_path: String::new(),
socket_path: "/tmp/fips_control.sock".to_string(),
}
}
}
/// Initialize FIPS control.
pub fn fips_control_init() {
let mut state = G_FIPS_CONTROL.lock().unwrap();
state.running = false;
}
/// Start the FIPS mesh node.
pub fn fips_control_start() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_FIPS_CONTROL.lock().unwrap();
if state.running {
return Ok(());
}
// Try to connect to the FIPS control socket to verify it's running.
let socket_path = state.socket_path.clone();
if UnixStream::connect(&socket_path).is_ok() {
state.running = true;
println!("[fips] FIPS mesh node connected via {}", socket_path);
Ok(())
} else {
// FIPS daemon not running — mark as not running.
println!("[fips] FIPS control socket {} not available", socket_path);
Err(format!("FIPS control socket {} not available", socket_path).into())
}
}
/// Stop the FIPS mesh node.
pub fn fips_control_stop() {
let mut state = G_FIPS_CONTROL.lock().unwrap();
state.running = false;
println!("[fips] FIPS mesh node stopped");
}
/// Check if FIPS is running.
pub fn fips_control_is_running() -> bool {
let state = G_FIPS_CONTROL.lock().unwrap();
state.running
}
/// Send a JSON-line command to the FIPS control socket and return the
/// parsed response JSON.
fn fips_request(command: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let state = G_FIPS_CONTROL.lock().unwrap();
let socket_path = state.socket_path.clone();
drop(state);
let mut stream = UnixStream::connect(&socket_path)?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
// Send the command as a JSON line.
let request = serde_json::json!({ "command": command });
let line = format!("{}\n", serde_json::to_string(&request)?);
stream.write_all(line.as_bytes())?;
// Read the response (until newline).
let mut reader = BufReader::new(stream);
let mut response = String::new();
let n = reader.read_line(&mut response)?;
if n == 0 {
return Err("FIPS closed control connection".into());
}
let root: serde_json::Value = serde_json::from_str(&response)?;
if root.get("status").and_then(|s| s.as_str()) != Some("ok") {
let msg = root.get("message").and_then(|m| m.as_str()).unwrap_or("request failed");
return Err(format!("FIPS control error: {}", msg).into());
}
Ok(root)
}
/// Get the FIPS status.
pub fn fips_control_show_status() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let root = fips_request("show_status")?;
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
}
/// Get the FIPS peer list.
pub fn fips_control_show_peers() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let root = fips_request("show_peers")?;
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
}
/// Get the FIPS spanning tree.
pub fn fips_control_show_tree() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let root = fips_request("show_tree")?;
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
}
/// Get the FIPS identity cache.
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let root = fips_request("show_identity_cache")?;
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
}
/// Connect to a FIPS peer.
pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let state = G_FIPS_CONTROL.lock().unwrap();
let socket_path = state.socket_path.clone();
drop(state);
let mut stream = UnixStream::connect(&socket_path)?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
let request = serde_json::json!({
"command": "connect_peer",
"npub": npub,
"address": address,
"transport": transport,
});
let line = format!("{}\n", serde_json::to_string(&request)?);
stream.write_all(line.as_bytes())?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response)?;
let root: serde_json::Value = serde_json::from_str(&response)?;
Ok(root)
}
/// Disconnect from a FIPS peer.
pub fn fips_control_disconnect_peer(npub: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let state = G_FIPS_CONTROL.lock().unwrap();
let socket_path = state.socket_path.clone();
drop(state);
let mut stream = UnixStream::connect(&socket_path)?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
let request = serde_json::json!({
"command": "disconnect_peer",
"npub": npub,
});
let line = format!("{}\n", serde_json::to_string(&request)?);
stream.write_all(line.as_bytes())?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response)?;
let root: serde_json::Value = serde_json::from_str(&response)?;
Ok(root)
}
/// Resolve a FIPS address (npub) to a reachable endpoint.
pub fn fips_control_resolve(npub: &str) -> Option<String> {
// Query the identity cache for the npub's address.
if let Ok(cache) = fips_control_show_identity_cache() {
if let Some(nodes) = cache.get("nodes").and_then(|n| n.as_array()) {
for node in nodes {
if node.get("npub").and_then(|n| n.as_str()) == Some(npub) {
if let Some(addr) = node.get("address").and_then(|a| a.as_str()) {
return Some(addr.to_string());
}
}
}
}
}
None
}
+20
View File
@@ -0,0 +1,20 @@
//! Browsing history management
//!
//! Port of `history.c` / `history.h` from the C project.
use crate::db;
/// Add a history entry.
pub fn history_add(url: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
db::db_add_history(url, title)
}
/// Get history entries.
pub fn history_get(limit: u64) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
db::db_get_history(limit)
}
/// Clear all history.
pub fn history_clear() -> Result<(), Box<dyn std::error::Error>> {
db::db_clear_history()
}
+188
View File
@@ -0,0 +1,188 @@
//! Nostr identity (in-memory only) for sovereign_browser.
//!
//! IMPORTANT: Private keys are NEVER persisted to disk. They live only in
//! working memory (RAM) for the duration of the session and are gone when
//! the browser quits. The user must sign in again on each launch.
//!
//! Port of `key_store.h` / `key_store.c` from the C project.
//! Uses `nostr_signer` from `rust_core_lib` instead of `nostr_signer_t`.
use nostr_core::types::SecretKey;
use nostr_signer::local::LocalSigner;
use nostr_signer::traits::NostrSigner;
use std::path::PathBuf;
use std::sync::Arc;
/// Login methods supported by the browser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyStoreMethod {
None,
Local, // nsec — private key in memory only
Seed, // BIP-39 mnemonic — private key re-derived
Readonly, // npub only — no signing
Nip46, // bunker:// URL — remote signer session
Nsigner, // n_signer hardware — device path + index
}
impl KeyStoreMethod {
pub fn as_str(&self) -> &'static str {
match self {
KeyStoreMethod::None => "none",
KeyStoreMethod::Local => "local",
KeyStoreMethod::Seed => "seed",
KeyStoreMethod::Readonly => "readonly",
KeyStoreMethod::Nip46 => "nip46",
KeyStoreMethod::Nsigner => "nsigner",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"local" => KeyStoreMethod::Local,
"seed" => KeyStoreMethod::Seed,
"readonly" => KeyStoreMethod::Readonly,
"nip46" => KeyStoreMethod::Nip46,
"nsigner" => KeyStoreMethod::Nsigner,
_ => KeyStoreMethod::None,
}
}
}
/// Identity record (in-memory only — never persisted).
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct KeyStoreIdentity {
pub method: KeyStoreMethod,
/// Hex pubkey (always present, 64 chars).
pub pubkey_hex: String,
/// Hex private key (local + seed methods only).
pub privkey_hex: String,
/// BIP-39 mnemonic (seed method only).
pub mnemonic: String,
/// NIP-46 bunker URL (nip46 method only).
pub bunker_url: String,
/// n_signer transport (nsigner method only).
pub nsigner_transport: String,
/// n_signer device path / socket name / host:port / qube.
pub nsigner_device: String,
/// NIP-06 index.
pub nsigner_index: i32,
/// Role name.
pub nsigner_role: String,
}
impl Default for KeyStoreIdentity {
fn default() -> Self {
KeyStoreIdentity {
method: KeyStoreMethod::None,
pubkey_hex: String::new(),
privkey_hex: String::new(),
mnemonic: String::new(),
bunker_url: String::new(),
nsigner_transport: String::new(),
nsigner_device: String::new(),
nsigner_index: 0,
nsigner_role: "nostr_range".to_string(),
}
}
}
/// Create a signer from an in-memory identity.
/// For readonly: returns None (caller should use pubkey_hex directly).
pub fn key_store_create_signer(identity: &KeyStoreIdentity) -> Option<Arc<dyn NostrSigner>> {
match identity.method {
KeyStoreMethod::Local | KeyStoreMethod::Seed => {
if identity.privkey_hex.is_empty() {
return None;
}
let bytes = hex::decode(&identity.privkey_hex).ok()?;
if bytes.len() != 32 {
return None;
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
let sk = SecretKey::from_bytes(arr);
LocalSigner::new(sk).ok().map(|s| Arc::new(s) as Arc<dyn NostrSigner>)
}
KeyStoreMethod::Readonly => None,
KeyStoreMethod::Nip46 => {
// NIP-46 remote signer — not yet implemented in rust_core_lib
None
}
KeyStoreMethod::Nsigner => {
// n_signer hardware — not yet implemented in rust_core_lib
None
}
KeyStoreMethod::None => None,
}
}
/// Get the data directory for sovereign_browser.
fn data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser")
}
/// Get the profiles directory.
fn profiles_dir() -> PathBuf {
data_dir().join("profiles")
}
/// Get the profile directory for a given pubkey.
fn profile_dir(pubkey_hex: &str) -> PathBuf {
profiles_dir().join(pubkey_hex)
}
/// Save public identity info (method, pubkey_hex) to the per-profile
/// identity.json. Private keys are NEVER stored.
pub fn key_store_save_profile_identity(
pubkey_hex: &str,
method: KeyStoreMethod,
) -> std::io::Result<()> {
let dir = profile_dir(pubkey_hex);
std::fs::create_dir_all(&dir)?;
let info = serde_json::json!({
"pubkey_hex": pubkey_hex,
"method": method.as_str(),
});
let path = dir.join("identity.json");
std::fs::write(path, serde_json::to_string_pretty(&info)?)?;
Ok(())
}
/// Load public identity info from the per-profile identity.json.
pub fn key_store_load_profile_identity(
pubkey_hex: &str,
) -> std::io::Result<KeyStoreMethod> {
let path = profile_dir(pubkey_hex).join("identity.json");
let content = std::fs::read_to_string(path)?;
let info: serde_json::Value = serde_json::from_str(&content)?;
let stored_pubkey = info["pubkey_hex"]
.as_str()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing pubkey"))?;
if stored_pubkey != pubkey_hex {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"pubkey mismatch",
));
}
let method_str = info["method"]
.as_str()
.unwrap_or("none");
Ok(KeyStoreMethod::from_str(method_str))
}
/// Defensively delete any legacy identity.json file from a previous
/// version that persisted keys to disk.
pub fn key_store_delete_legacy_file() -> std::io::Result<()> {
let legacy = data_dir().join("identity.json");
if legacy.exists() {
std::fs::remove_file(legacy)?;
}
Ok(())
}
+66
View File
@@ -0,0 +1,66 @@
//! # sovereign_browser — WebKitGTK + Rust
//!
//! A Linux x86 web browser built on WebKitGTK, designed around
//! **sovereign identity** instead of the traditional web's permissioned
//! infrastructure. Uses `rust_core_lib` for all Nostr operations.
//!
//! ## Architecture
//!
//! This is a Rust port of the C99 `sovereign_browser` project. The Nostr
//! functionality previously provided by `nostr_core_lib` (C library) is
//! now provided by the `rust_core_lib` workspace crates:
//!
//! - `nostr_core` — Core types, crypto, utilities
//! - `nostr_relay` — Relay networking (WebSocket, HTTP, pool)
//! - `nostr_nips` — NIP implementations
//! - `nostr_signer` — Signer abstraction (local + nsigner)
//! - `nostr_services` — Blossom, Cashu, validator services
//!
//! Many functions are defined as part of the port but not yet wired into
//! the UI; suppress dead-code warnings until the port is complete.
#![allow(dead_code)]
pub mod nostr_bridge;
pub mod nostr_inject;
pub mod nostr_url;
pub mod nostr_scheme;
pub mod key_store;
pub mod login_dialog;
pub mod relay_fetch;
pub mod settings_sync;
pub mod agent_conversations;
pub mod agent_skills;
pub mod agent_tools;
pub mod agent_loop;
pub mod agent_chat;
pub mod agent_chat_store;
pub mod agent_mcp;
pub mod agent_server;
pub mod agent_login;
pub mod agent_snapshot;
pub mod agent_fs_tools;
pub mod agent_llm;
pub mod db;
pub mod settings;
pub mod tab_manager;
pub mod menu;
pub mod session;
pub mod bookmarks;
pub mod history;
pub mod search;
pub mod shortcuts;
pub mod profile;
pub mod net_services;
pub mod tor_control;
pub mod tor_scheme;
pub mod fips_control;
pub mod web_context;
pub mod webkit_data;
pub mod site_downloader;
pub mod local_scheme;
pub mod cli;
pub mod qr;
pub mod process_info;
pub mod perf_probe;
pub mod embedded_web_content;
pub mod version;
+54
View File
@@ -0,0 +1,54 @@
//! WebKitGTK local:// URI scheme handler for local file browsing
//!
//! Port of `local_scheme.c` / `local_scheme.h` from the C project.
use webkit2gtk::*;
use gio::MemoryInputStream;
pub fn local_scheme_register(ctx: &WebContext) {
ctx.register_uri_scheme("local", |request| {
handle_local_scheme(request);
});
}
fn handle_local_scheme(request: &URISchemeRequest) {
let uri = request.uri().unwrap_or_default();
let path = uri.strip_prefix("local://").unwrap_or(&uri);
let file_path = std::path::Path::new(path);
if file_path.exists() && file_path.is_file() {
match std::fs::read_to_string(file_path) {
Ok(content) => {
let mime_type = guess_mime_type(file_path);
let body = content.into_bytes();
let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some(&mime_type));
return;
}
Err(_) => {}
}
}
let html = format!("<html><body><h1>File Not Found</h1><p>Path: <code>{}</code></p></body></html>", path);
let body = html.into_bytes();
let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("text/html"));
}
fn guess_mime_type(path: &std::path::Path) -> String {
match path.extension().and_then(|e| e.to_str()) {
Some("html") | Some("htm") => "text/html".to_string(),
Some("css") => "text/css".to_string(),
Some("js") => "application/javascript".to_string(),
Some("json") => "application/json".to_string(),
Some("png") => "image/png".to_string(),
Some("jpg") | Some("jpeg") => "image/jpeg".to_string(),
Some("gif") => "image/gif".to_string(),
Some("svg") => "image/svg+xml".to_string(),
Some("ico") => "image/x-icon".to_string(),
Some("txt") => "text/plain".to_string(),
Some("xml") => "application/xml".to_string(),
Some("pdf") => "application/pdf".to_string(),
_ => "application/octet-stream".to_string(),
}
}
+905
View File
@@ -0,0 +1,905 @@
//! GTK Nostr login dialog for sovereign_browser
//!
//! Port of `login_dialog.c` / `login_dialog.h` from the C project.
//!
//! Presents a modal GTK dialog with notebook tabs for each login method:
//! Local Key, Seed Phrase, Read-only, NIP-46, and n_signer. Also offers a
//! "No Login" option to browse without a Nostr identity.
use gtk::prelude::*;
use nostr_core::crypto::keys::generate_keypair;
use nostr_core::types::SecretKey;
use nostr_core::util::bech32::{bech32_decode, Bech32Entity, nsec_encode};
use nostr_signer::local::LocalSigner;
use nostr_signer::traits::NostrSigner;
use rand::RngCore;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use crate::key_store::{KeyStoreIdentity, KeyStoreMethod};
use crate::version;
const SEED_WORD_COUNT: usize = 12;
pub struct LoginResult {
pub method: KeyStoreMethod,
pub signer: Option<Arc<dyn NostrSigner>>,
pub pubkey_hex: String,
pub identity: KeyStoreIdentity,
/// True when the user chose "No Login" (browse without an identity).
pub no_login: bool,
}
/// Shared dialog state, wrapped in Rc<RefCell> so GTK callbacks can access it.
struct LoginCtx {
dialog: gtk::Dialog,
notebook: gtk::Notebook,
status_label: gtk::Label,
result: Option<LoginResult>,
done: bool,
no_login: bool,
// Local Key screen
local_entry: gtk::Entry,
// Seed Phrase screen
seed_entries: Vec<gtk::Entry>,
account_spin: gtk::SpinButton,
// Read-only screen
readonly_entry: gtk::Entry,
// NIP-46 screen
nip46_entry: gtk::Entry,
// n_signer screen
transport_combo: gtk::ComboBoxText,
device_label: gtk::Label,
device_entry: gtk::Entry,
enum_btn: gtk::Button,
service_box: gtk::Box,
service_entry: gtk::Entry,
role_entry: gtk::Entry,
index_spin: gtk::SpinButton,
index_label: gtk::Label,
}
impl LoginCtx {
fn set_status(&self, msg: &str) {
self.status_label.set_text(msg);
}
fn current_method(&self) -> &'static str {
let page = self.notebook.current_page().unwrap_or(0);
match page {
0 => "local",
1 => "seed",
2 => "readonly",
3 => "nip46",
4 => "nsigner",
_ => "local",
}
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
pub fn derive_pubkey_hex(sk: &SecretKey) -> Option<String> {
let signer = LocalSigner::new(sk.clone()).ok()?;
let pk = signer.get_public_key().ok()?;
Some(pk.to_hex())
}
pub fn decode_nsec(input: &str) -> Option<SecretKey> {
if let Ok(Bech32Entity::Nsec(sk)) = bech32_decode(input) {
return Some(sk);
}
// Fall back to 64-char hex
if input.len() == 64 {
if let Ok(sk) = input.parse::<SecretKey>() {
return Some(sk);
}
}
None
}
pub fn decode_npub_hex(input: &str) -> Option<String> {
if let Ok(Bech32Entity::Npub(pk)) = bech32_decode(input) {
return Some(pk.to_hex());
}
// Fall back to 64-char hex
if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(input.to_string());
}
None
}
// ── Screen creation ────────────────────────────────────────────────────────
fn create_local_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
box_.set_margin_top(12);
box_.set_margin_bottom(12);
box_.set_margin_start(12);
box_.set_margin_end(12);
let label = gtk::Label::new(Some(
"Enter your Nostr private key (nsec) or generate a new one:",
));
label.set_halign(gtk::Align::Start);
box_.pack_start(&label, false, false, 0);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("nsec1..."));
entry.set_width_chars(60);
box_.pack_start(&entry, false, false, 0);
let gen_btn = gtk::Button::with_label("Generate New Key");
let entry_clone = entry.clone();
gen_btn.connect_clicked(move |_| {
let (sk, _pk) = generate_keypair();
if let Ok(nsec) = nsec_encode(&sk) {
entry_clone.set_text(&nsec);
} else {
entry_clone.set_text("(generation failed)");
}
});
box_.pack_start(&gen_btn, false, false, 0);
ctx.borrow_mut().local_entry = entry;
box_
}
fn create_seed_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
box_.set_margin_top(12);
box_.set_margin_bottom(12);
box_.set_margin_start(12);
box_.set_margin_end(12);
let label = gtk::Label::new(Some("Enter your 12-word BIP-39 seed phrase:"));
label.set_halign(gtk::Align::Start);
box_.pack_start(&label, false, false, 0);
// Build the BIP-39 word completion model (shared by all 12 entries).
let store = gtk::ListStore::new(&[glib::Type::STRING]);
for word in nostr_nips::nip006::bip39_wordlist() {
let iter = store.append();
store.set_value(&iter, 0, &word.to_value());
}
// 12 numbered word entry boxes in a 4x3 grid with inline auto-complete.
let grid = gtk::Grid::new();
grid.set_row_spacing(4);
grid.set_column_spacing(8);
box_.pack_start(&grid, false, false, 0);
let mut entries: Vec<gtk::Entry> = Vec::with_capacity(SEED_WORD_COUNT);
for i in 0..SEED_WORD_COUNT {
let col = (i % 4) as i32;
let row = (i / 4) as i32;
let num_label = gtk::Label::new(Some(&format!("{}.", i + 1)));
num_label.set_halign(gtk::Align::End);
grid.attach(&num_label, col * 2, row, 1, 1);
let entry = gtk::Entry::new();
entry.set_width_chars(14);
entry.set_placeholder_text(Some("word"));
// Dropdown completion list.
let completion = gtk::EntryCompletion::new();
completion.set_model(Some(&store));
completion.set_text_column(0);
completion.set_minimum_key_length(2);
entry.set_completion(Some(&completion));
grid.attach(&entry, col * 2 + 1, row, 1, 1);
entries.push(entry);
}
// Tab/Enter moves to the next word box.
for i in 0..SEED_WORD_COUNT {
let entries_clone = entries.clone();
let idx = i;
entries[i].connect_key_press_event(move |_entry, event| {
if event.keyval() == gdk::keys::constants::Tab
|| event.keyval() == gdk::keys::constants::Return
|| event.keyval() == gdk::keys::constants::KP_Enter
{
if idx + 1 < SEED_WORD_COUNT {
entries_clone[idx + 1].grab_focus();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
}
let gen_btn = gtk::Button::with_label("Generate New Mnemonic");
let entries_clone = entries.clone();
gen_btn.connect_clicked(move |_| {
let mut entropy = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut entropy);
if let Ok(mnemonic) = nostr_nips::nip006::mnemonic_from_bytes(&entropy) {
let words: Vec<&str> = mnemonic.split_whitespace().collect();
for (i, entry) in entries_clone.iter().enumerate() {
if let Some(w) = words.get(i) {
entry.set_text(w);
}
}
if let Some(first) = entries_clone.first() {
first.grab_focus();
}
}
});
box_.pack_start(&gen_btn, false, false, 0);
// Account number selector.
let acct_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
box_.pack_start(&acct_box, false, false, 0);
let acct_label = gtk::Label::new(Some("Account #:"));
acct_box.pack_start(&acct_label, false, false, 0);
let acct_spin = gtk::SpinButton::with_range(0.0, 1000.0, 1.0);
acct_spin.set_value(0.0);
acct_box.pack_start(&acct_spin, false, false, 0);
let hint = gtk::Label::new(Some("Key derivation: m/44'/1237'/0'/0/0"));
hint.set_sensitive(false);
hint.set_halign(gtk::Align::Start);
box_.pack_start(&hint, false, false, 0);
let hint_clone = hint.clone();
acct_spin.connect_value_changed(move |spin| {
let account = spin.value() as i32;
hint_clone.set_text(&format!("Key derivation: m/44'/1237'/{}'/0/0", account));
});
ctx.borrow_mut().seed_entries = entries;
ctx.borrow_mut().account_spin = acct_spin;
box_
}
fn create_readonly_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
box_.set_margin_top(12);
box_.set_margin_bottom(12);
box_.set_margin_start(12);
box_.set_margin_end(12);
let label = gtk::Label::new(Some(
"Enter a Nostr public key (npub) for read-only mode:",
));
label.set_halign(gtk::Align::Start);
box_.pack_start(&label, false, false, 0);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("npub1..."));
entry.set_width_chars(60);
box_.pack_start(&entry, false, false, 0);
let hint = gtk::Label::new(Some(
"Read-only mode: you can view content but cannot sign events.",
));
hint.set_sensitive(false);
hint.set_halign(gtk::Align::Start);
box_.pack_start(&hint, false, false, 0);
ctx.borrow_mut().readonly_entry = entry;
box_
}
fn create_nip46_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
box_.set_margin_top(12);
box_.set_margin_bottom(12);
box_.set_margin_start(12);
box_.set_margin_end(12);
let label = gtk::Label::new(Some(
"Connect to a NIP-46 remote signer (bunker:// URL):",
));
label.set_halign(gtk::Align::Start);
box_.pack_start(&label, false, false, 0);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("bunker://<pubkey>?relay=wss://...&secret=..."));
entry.set_width_chars(60);
box_.pack_start(&entry, false, false, 0);
let hint = gtk::Label::new(Some(
"The remote signer holds your private key. Signing requests are sent over Nostr relays.",
));
hint.set_sensitive(false);
hint.set_halign(gtk::Align::Start);
hint.set_line_wrap(true);
box_.pack_start(&hint, false, false, 0);
ctx.borrow_mut().nip46_entry = entry;
box_
}
fn create_nsigner_screen(ctx: &Rc<RefCell<LoginCtx>>) -> gtk::Box {
let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8);
box_.set_margin_top(12);
box_.set_margin_bottom(12);
box_.set_margin_start(12);
box_.set_margin_end(12);
let label = gtk::Label::new(Some("Connect to n_signer hardware signer:"));
label.set_halign(gtk::Align::Start);
box_.pack_start(&label, false, false, 0);
// Transport type selector.
let transport_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
box_.pack_start(&transport_box, false, false, 0);
let transport_label = gtk::Label::new(Some("Transport:"));
transport_box.pack_start(&transport_label, false, false, 0);
let transport_combo = gtk::ComboBoxText::new();
transport_combo.append_text("USB Serial");
transport_combo.append_text("UNIX Socket");
transport_combo.append_text("TCP");
transport_combo.append_text("Other Qube");
transport_combo.set_active(Some(3));
transport_box.pack_start(&transport_combo, false, false, 0);
// Device path entry — label and placeholder adapt to transport type.
let device_label = gtk::Label::new(Some("Target Qube:"));
device_label.set_halign(gtk::Align::Start);
box_.pack_start(&device_label, false, false, 0);
let device_entry = gtk::Entry::new();
device_entry.set_text("nostr_signer");
device_entry.set_placeholder_text(Some("nostr_signer"));
device_entry.set_width_chars(40);
box_.pack_start(&device_entry, false, false, 0);
// Enumerate serial devices button (only shown for serial transport).
let enum_btn = gtk::Button::with_label("Detect Serial Devices");
enum_btn.set_sensitive(false);
box_.pack_start(&enum_btn, false, false, 0);
enum_btn.hide();
// Service name field (only shown for qrexec transport).
let service_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
box_.pack_start(&service_box, false, false, 0);
let service_label = gtk::Label::new(Some("Service:"));
service_box.pack_start(&service_label, false, false, 0);
let service_entry = gtk::Entry::new();
service_entry.set_text("qubes.NsignerRpc");
service_entry.set_width_chars(30);
service_box.pack_start(&service_entry, false, false, 0);
// Role entry field.
let role_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
box_.pack_start(&role_box, false, false, 0);
let role_label = gtk::Label::new(Some("Role:"));
role_box.pack_start(&role_label, false, false, 0);
let role_entry = gtk::Entry::new();
role_entry.set_text("nostr_range");
role_entry.set_placeholder_text(Some("nostr_range"));
role_entry.set_width_chars(30);
role_box.pack_start(&role_entry, false, false, 0);
// Key index spinner with live BIP-32 path label.
let index_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
box_.pack_start(&index_box, false, false, 0);
let index_label = gtk::Label::new(Some("Key Index (m/44'/1237'/0'/0/0):"));
index_label.set_halign(gtk::Align::Start);
index_box.pack_start(&index_label, false, false, 0);
let index_spin = gtk::SpinButton::with_range(0.0, 1000.0, 1.0);
index_spin.set_value(0.0);
index_box.pack_start(&index_spin, false, false, 0);
let index_label_clone = index_label.clone();
index_spin.connect_value_changed(move |spin| {
let idx = spin.value() as i32;
index_label_clone.set_text(&format!("Key Index (m/44'/1237'/{}'/0/0):", idx));
});
let hint = gtk::Label::new(Some(
"n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.",
));
hint.set_sensitive(false);
hint.set_halign(gtk::Align::Start);
hint.set_line_wrap(true);
box_.pack_start(&hint, false, false, 0);
// Transport change callback to update the UI dynamically.
let device_label_clone = device_label.clone();
let device_entry_clone = device_entry.clone();
let enum_btn_clone = enum_btn.clone();
let service_box_clone = service_box.clone();
transport_combo.connect_changed(move |combo| {
let idx = combo.active().unwrap_or(0);
match idx {
0 => {
device_label_clone.set_text("Device Path:");
device_entry_clone.set_placeholder_text(Some("/dev/ttyACM0"));
if device_entry_clone.text() == "nostr_signer" {
device_entry_clone.set_text("");
}
enum_btn_clone.show();
service_box_clone.hide();
}
1 => {
device_label_clone.set_text("Socket Name:");
device_entry_clone.set_placeholder_text(Some("nsigner"));
if device_entry_clone.text() == "nostr_signer" {
device_entry_clone.set_text("");
}
enum_btn_clone.hide();
service_box_clone.hide();
}
2 => {
device_label_clone.set_text("Host:Port:");
device_entry_clone.set_placeholder_text(Some("127.0.0.1:7777"));
if device_entry_clone.text() == "nostr_signer" {
device_entry_clone.set_text("");
}
enum_btn_clone.hide();
service_box_clone.hide();
}
_ => {
device_label_clone.set_text("Target Qube:");
device_entry_clone.set_placeholder_text(Some("nostr_signer"));
if device_entry_clone.text().is_empty() {
device_entry_clone.set_text("nostr_signer");
}
enum_btn_clone.hide();
service_box_clone.show();
}
}
});
ctx.borrow_mut().transport_combo = transport_combo;
ctx.borrow_mut().device_label = device_label;
ctx.borrow_mut().device_entry = device_entry;
ctx.borrow_mut().enum_btn = enum_btn;
ctx.borrow_mut().service_box = service_box;
ctx.borrow_mut().service_entry = service_entry;
ctx.borrow_mut().role_entry = role_entry;
ctx.borrow_mut().index_spin = index_spin;
ctx.borrow_mut().index_label = index_label;
box_
}
// ── Login handler ──────────────────────────────────────────────────────────
fn on_login_clicked(ctx: &Rc<RefCell<LoginCtx>>) {
let method = ctx.borrow().current_method();
let mut ctx_borrow = ctx.borrow_mut();
match method {
"local" => {
let input = ctx_borrow.local_entry.text().to_string();
if input.is_empty() {
ctx_borrow.set_status("Enter an nsec or click Generate.");
return;
}
let sk = match decode_nsec(&input) {
Some(sk) => sk,
None => {
ctx_borrow.set_status("Enter an nsec1... or 64-char hex key.");
return;
}
};
let pubkey_hex = match derive_pubkey_hex(&sk) {
Some(h) => h,
None => {
ctx_borrow.set_status("Failed to derive public key.");
return;
}
};
let signer = match LocalSigner::new(sk.clone()) {
Ok(s) => s,
Err(_) => {
ctx_borrow.set_status("Failed to create signer.");
return;
}
};
let identity = KeyStoreIdentity {
method: KeyStoreMethod::Local,
pubkey_hex: pubkey_hex.clone(),
privkey_hex: sk.to_hex(),
..Default::default()
};
ctx_borrow.result = Some(LoginResult {
method: KeyStoreMethod::Local,
signer: Some(Arc::new(signer)),
pubkey_hex,
identity,
no_login: false,
});
ctx_borrow.done = true;
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
}
"seed" => {
let _account = ctx_borrow.account_spin.value() as i32;
let mut mnemonic = String::new();
let mut words_filled = 0;
for entry in &ctx_borrow.seed_entries {
let word = entry.text().to_string();
if !word.is_empty() {
if words_filled > 0 {
mnemonic.push(' ');
}
mnemonic.push_str(&word);
words_filled += 1;
}
}
if words_filled == 0 {
ctx_borrow.set_status("Enter your seed phrase or click Generate.");
return;
}
if words_filled != SEED_WORD_COUNT {
ctx_borrow.set_status(&format!(
"Expected 12 words, got {}.",
words_filled
));
return;
}
if !nostr_nips::nip006::mnemonic_validate(&mnemonic) {
ctx_borrow.set_status("Invalid seed phrase. Check the words and try again.");
return;
}
let seed = nostr_nips::nip006::mnemonic_to_seed(&mnemonic, "");
let (sk, pk) = match nostr_nips::nip006::keypair_from_seed(&seed) {
Ok(kp) => kp,
Err(_) => {
ctx_borrow.set_status("Invalid seed phrase. Check the words and try again.");
return;
}
};
let pubkey_hex = pk.to_hex();
let signer = match LocalSigner::new(sk.clone()) {
Ok(s) => s,
Err(_) => {
ctx_borrow.set_status("Failed to create signer.");
return;
}
};
let identity = KeyStoreIdentity {
method: KeyStoreMethod::Seed,
pubkey_hex: pubkey_hex.clone(),
privkey_hex: sk.to_hex(),
mnemonic,
..Default::default()
};
ctx_borrow.result = Some(LoginResult {
method: KeyStoreMethod::Seed,
signer: Some(Arc::new(signer)),
pubkey_hex,
identity,
no_login: false,
});
ctx_borrow.done = true;
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
}
"readonly" => {
let input = ctx_borrow.readonly_entry.text().to_string();
if input.is_empty() {
ctx_borrow.set_status("Enter an npub1... or 64-char hex pubkey.");
return;
}
let pubkey_hex = match decode_npub_hex(&input) {
Some(h) => h,
None => {
ctx_borrow.set_status("Enter an npub1... or 64-char hex pubkey.");
return;
}
};
let identity = KeyStoreIdentity {
method: KeyStoreMethod::Readonly,
pubkey_hex: pubkey_hex.clone(),
..Default::default()
};
ctx_borrow.result = Some(LoginResult {
method: KeyStoreMethod::Readonly,
signer: None,
pubkey_hex,
identity,
no_login: false,
});
ctx_borrow.done = true;
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
}
"nip46" => {
let bunker_url = ctx_borrow.nip46_entry.text().to_string();
if bunker_url.is_empty() {
ctx_borrow.set_status("Enter a bunker:// URL.");
return;
}
let bunker = match nostr_nips::nip046::parse_bunker_url(&bunker_url) {
Ok(b) => b,
Err(_) => {
ctx_borrow.set_status(
"Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...",
);
return;
}
};
let pubkey_hex = bunker.pubkey.to_hex();
// Generate a client keypair for the NIP-46 session.
let (client_sk, _) = generate_keypair();
let signer = match LocalSigner::new(client_sk.clone()) {
Ok(s) => s,
Err(_) => {
ctx_borrow.set_status("Failed to create client signer.");
return;
}
};
let identity = KeyStoreIdentity {
method: KeyStoreMethod::Nip46,
pubkey_hex: pubkey_hex.clone(),
privkey_hex: client_sk.to_hex(),
bunker_url,
..Default::default()
};
ctx_borrow.result = Some(LoginResult {
method: KeyStoreMethod::Nip46,
signer: Some(Arc::new(signer)),
pubkey_hex,
identity,
no_login: false,
});
ctx_borrow.done = true;
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
}
"nsigner" => {
let device = ctx_borrow.device_entry.text().to_string();
let role = {
let r = ctx_borrow.role_entry.text().to_string();
if r.is_empty() {
"nostr_range".to_string()
} else {
r
}
};
let nostr_index = ctx_borrow.index_spin.value() as i32;
let transport_idx = ctx_borrow.transport_combo.active().unwrap_or(0);
if device.is_empty() {
ctx_borrow.set_status("Enter a device path or select one.");
return;
}
// Build the role path: m/44'/1237'/N'/0/0
let role_path = format!("m/44'/1237'/{}'/0/0", nostr_index);
let signer: Option<Arc<dyn NostrSigner>> = match transport_idx {
1 => {
let s = nostr_signer::nsigner::NsignerSigner::from_transport(
Box::new(nostr_signer::nsigner::UnixTransport::new(&device, 15000)),
&role,
);
s.set_role_path(&role_path);
Some(Arc::new(s))
}
2 => {
// Parse host:port
if let Some((host, port)) = device.rsplit_once(':') {
if let Ok(port) = port.parse::<u16>() {
let s = nostr_signer::nsigner::NsignerSigner::from_transport(
Box::new(nostr_signer::nsigner::TcpTransport::new(
host, port, 15000,
)),
&role,
);
s.set_role_path(&role_path);
Some(Arc::new(s))
} else {
None
}
} else {
None
}
}
_ => {
// USB Serial and Other Qube (qrexec) are not supported by
// the Rust nsigner crate yet.
ctx_borrow.set_status(
"This transport is not supported in this build. Use UNIX Socket or TCP.",
);
return;
}
};
let signer = match signer {
Some(s) => s,
None => {
ctx_borrow.set_status(
"Failed to connect to n_signer. Check the device/path/qube.",
);
return;
}
};
let pubkey_hex = match signer.get_public_key() {
Ok(pk) => pk.to_hex(),
Err(e) => {
let desc = signer
.last_error()
.unwrap_or_else(|| format!("{}", e));
ctx_borrow.set_status(&format!(
"n_signer error: {} (code {:?}). Try a different key index.",
desc, e
));
return;
}
};
let transport_name = match transport_idx {
1 => "unix",
2 => "tcp",
_ => "unknown",
};
let identity = KeyStoreIdentity {
method: KeyStoreMethod::Nsigner,
pubkey_hex: pubkey_hex.clone(),
nsigner_transport: transport_name.to_string(),
nsigner_device: device,
nsigner_index: nostr_index,
nsigner_role: role,
..Default::default()
};
ctx_borrow.result = Some(LoginResult {
method: KeyStoreMethod::Nsigner,
signer: Some(signer),
pubkey_hex,
identity,
no_login: false,
});
ctx_borrow.done = true;
ctx_borrow.dialog.response(gtk::ResponseType::Accept);
}
_ => {
ctx_borrow.set_status("Unknown method.");
}
}
}
// ── Main dialog ────────────────────────────────────────────────────────────
pub fn login_dialog_run(parent: &gtk::Window) -> Option<LoginResult> {
let dialog = gtk::Dialog::new();
dialog.set_title(&format!("sovereign browser {}", version::VERSION));
dialog.set_modal(true);
dialog.set_transient_for(Some(parent));
dialog.set_default_size(560, 380);
// Custom buttons: No Login | Cancel | Sign In
let no_login_btn = gtk::Button::with_label("No Login");
let cancel_btn = gtk::Button::with_label("Cancel");
let login_btn = gtk::Button::with_label("Sign In");
dialog.add_action_widget(&no_login_btn, gtk::ResponseType::None);
dialog.add_action_widget(&cancel_btn, gtk::ResponseType::Cancel);
dialog.add_action_widget(&login_btn, gtk::ResponseType::Accept);
no_login_btn.set_tooltip_text(Some(
"Browse without a Nostr identity. The browser works normally, \
but window.nostr won't be available for signing.",
));
// Apply monospace font styling to the dialog.
let provider = gtk::CssProvider::new();
let _ = provider.load_from_data(
b"dialog { font-family: monospace; }\n\
label { font-family: monospace; }\n\
entry { font-family: monospace; }\n\
button { font-family: monospace; }\n",
);
let sctx = dialog.style_context();
sctx.add_provider(&provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION);
let content = dialog.content_area();
content.set_border_width(12);
// Title header.
let title = gtk::Label::new(None);
title.set_markup("<span size='large' weight='bold'>Sign in with your Nostr key</span>");
content.pack_start(&title, false, false, 8);
// Notebook with tabs for each login method.
let notebook = gtk::Notebook::new();
content.pack_start(&notebook, true, true, 4);
// Status label.
let status = gtk::Label::new(Some(""));
status.set_halign(gtk::Align::Start);
content.pack_start(&status, false, false, 4);
let ctx = Rc::new(RefCell::new(LoginCtx {
dialog: dialog.clone(),
notebook: notebook.clone(),
status_label: status,
result: None,
done: false,
no_login: false,
local_entry: gtk::Entry::new(),
seed_entries: Vec::new(),
account_spin: gtk::SpinButton::with_range(0.0, 1000.0, 1.0),
readonly_entry: gtk::Entry::new(),
nip46_entry: gtk::Entry::new(),
transport_combo: gtk::ComboBoxText::new(),
device_label: gtk::Label::new(None),
device_entry: gtk::Entry::new(),
enum_btn: gtk::Button::new(),
service_box: gtk::Box::new(gtk::Orientation::Horizontal, 0),
service_entry: gtk::Entry::new(),
role_entry: gtk::Entry::new(),
index_spin: gtk::SpinButton::with_range(0.0, 1000.0, 1.0),
index_label: gtk::Label::new(None),
}));
// Create all screens as notebook tabs.
let tabs: [(&str, fn(&Rc<RefCell<LoginCtx>>) -> gtk::Box); 5] = [
("Local Key", create_local_screen),
("Seed Phrase", create_seed_screen),
("Read-only", create_readonly_screen),
("NIP-46", create_nip46_screen),
("n_signer", create_nsigner_screen),
];
for (label, create_fn) in tabs {
let screen = create_fn(&ctx);
let tab_label = gtk::Label::new(Some(label));
notebook.append_page(&screen, Some(&tab_label));
}
// Wire the custom buttons.
let ctx_no_login = ctx.clone();
no_login_btn.connect_clicked(move |_| {
let mut c = ctx_no_login.borrow_mut();
c.done = true;
c.no_login = true;
c.result = None;
c.dialog.response(gtk::ResponseType::Accept);
});
let ctx_cancel = ctx.clone();
cancel_btn.connect_clicked(move |_| {
let mut c = ctx_cancel.borrow_mut();
c.done = false;
c.dialog.response(gtk::ResponseType::Cancel);
});
let ctx_login = ctx.clone();
login_btn.connect_clicked(move |_| {
on_login_clicked(&ctx_login);
});
dialog.show_all();
let response = dialog.run();
unsafe { dialog.destroy(); }
if response != gtk::ResponseType::Accept || !ctx.borrow().done {
return None;
}
if ctx.borrow().no_login {
// "No Login" — return a result with no identity.
return Some(LoginResult {
method: KeyStoreMethod::None,
signer: None,
pubkey_hex: String::new(),
identity: KeyStoreIdentity::default(),
no_login: true,
});
}
let result = ctx.borrow_mut().result.take();
result
}
+564
View File
@@ -0,0 +1,564 @@
//! sovereign_browser — WebKitGTK + Rust
//!
//! Multi-tab browser: a GTK window with a GtkNotebook tab strip.
//!
//! Many functions are defined as part of the port but not yet wired into
//! the UI; suppress dead-code warnings until the port is complete.
#![allow(dead_code)]
use gtk::prelude::*;
use webkit2gtk::*;
use std::sync::Arc;
mod nostr_bridge;
mod nostr_inject;
mod nostr_url;
mod nostr_scheme;
mod key_store;
mod login_dialog;
mod menu;
mod relay_fetch;
mod settings_sync;
mod agent_conversations;
mod agent_skills;
mod agent_tools;
mod agent_loop;
mod agent_chat;
mod agent_chat_store;
mod agent_mcp;
mod agent_server;
mod agent_login;
mod agent_snapshot;
mod agent_fs_tools;
mod agent_llm;
mod db;
mod settings;
mod tab_manager;
mod session;
mod bookmarks;
mod history;
mod search;
mod shortcuts;
mod profile;
mod net_services;
mod tor_control;
mod tor_scheme;
mod fips_control;
mod web_context;
mod webkit_data;
mod site_downloader;
mod local_scheme;
mod cli;
mod qr;
mod process_info;
mod perf_probe;
mod embedded_web_content;
mod version;
use nostr_signer::traits::NostrSigner;
/// Set the signer (delegates to the shared app state in `menu`).
fn app_set_signer(
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: &str,
privkey_hex: &str,
method: key_store::KeyStoreMethod,
readonly: bool,
) {
menu::app_set_signer(signer, pubkey_hex, privkey_hex, method, readonly);
}
/// Switch to the per-user profile database (delegates to `menu`).
fn switch_to_user_db(pubkey_hex: &str) -> i32 {
menu::switch_to_user_db(pubkey_hex)
}
/// Clear the signer and reset app state (delegates to `menu`).
pub fn app_clear_signer() {
menu::app_clear_signer();
}
fn do_login(parent: &gtk::Window) -> bool {
let result = login_dialog::login_dialog_run(parent);
match result {
Some(login_result) => {
let pubkey_hex = login_result.pubkey_hex.clone();
let privkey_hex = login_result.identity.privkey_hex.clone();
let method = login_result.method;
let readonly = login_result.signer.is_none();
// "No Login" mode — browse without a Nostr identity.
if login_result.no_login || pubkey_hex.is_empty() {
println!("[login] No-login mode (browsing without Nostr identity)");
app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true);
settings::settings_load();
shortcuts::shortcuts_load();
return true;
}
if switch_to_user_db(&pubkey_hex) != 0 { return false; }
app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly);
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method);
settings::settings_load();
shortcuts::shortcuts_load();
let pubkey = pubkey_hex.clone();
std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pubkey); });
true
}
None => false,
}
}
/// Auto-login from CLI arguments (no dialog).
/// Supports --local (nsec/hex), --pubkey (npub/hex, read-only), --seed (mnemonic).
fn do_cli_login(args: &cli::CliArgs) -> bool {
use nostr_signer::local::LocalSigner;
let method = match args.login_method {
Some(m) => m,
None => return false,
};
match method {
key_store::KeyStoreMethod::Local => {
let key_str = match &args.privkey {
Some(k) => k,
None => {
eprintln!("[login] --local requires a key argument");
return false;
}
};
let sk = match login_dialog::decode_nsec(key_str) {
Some(sk) => sk,
None => {
eprintln!("[login] Invalid nsec/hex key");
return false;
}
};
let pubkey_hex = match login_dialog::derive_pubkey_hex(&sk) {
Some(p) => p,
None => {
eprintln!("[login] Failed to derive pubkey");
return false;
}
};
let signer = match LocalSigner::new(sk.clone()) {
Ok(s) => Some(Arc::new(s) as Arc<dyn NostrSigner>),
Err(e) => {
eprintln!("[login] Failed to create signer: {}", e);
return false;
}
};
println!("[login] Logged in with local key: pubkey={}", pubkey_hex);
if switch_to_user_db(&pubkey_hex) != 0 { return false; }
app_set_signer(signer, &pubkey_hex, &sk.to_hex(), key_store::KeyStoreMethod::Local, false);
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Local);
settings::settings_load();
shortcuts::shortcuts_load();
let pk = pubkey_hex.clone();
std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); });
true
}
key_store::KeyStoreMethod::Readonly => {
let key_str = match &args.pubkey {
Some(k) => k,
None => {
eprintln!("[login] --pubkey requires a key argument");
return false;
}
};
let pubkey_hex = match login_dialog::decode_npub_hex(key_str) {
Some(p) => p,
None => {
eprintln!("[login] Invalid npub/hex key");
return false;
}
};
println!("[login] Logged in read-only: pubkey={}", pubkey_hex);
if switch_to_user_db(&pubkey_hex) != 0 { return false; }
app_set_signer(None, &pubkey_hex, "", key_store::KeyStoreMethod::Readonly, true);
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Readonly);
settings::settings_load();
shortcuts::shortcuts_load();
true
}
key_store::KeyStoreMethod::Seed => {
let mnemonic = match &args.mnemonic {
Some(m) => m,
None => {
eprintln!("[login] --seed requires a mnemonic argument");
return false;
}
};
let seed = nostr_nips::nip006::mnemonic_to_seed(mnemonic, "");
let (sk, _pk) = match nostr_nips::nip006::keypair_from_seed(&seed) {
Ok(kp) => kp,
Err(e) => {
eprintln!("[login] Failed to derive keypair from seed: {}", e);
return false;
}
};
let pubkey_hex = match login_dialog::derive_pubkey_hex(&sk) {
Some(p) => p,
None => {
eprintln!("[login] Failed to derive pubkey");
return false;
}
};
let signer = match LocalSigner::new(sk.clone()) {
Ok(s) => Some(Arc::new(s) as Arc<dyn NostrSigner>),
Err(e) => {
eprintln!("[login] Failed to create signer: {}", e);
return false;
}
};
println!("[login] Logged in with seed: pubkey={}", pubkey_hex);
if switch_to_user_db(&pubkey_hex) != 0 { return false; }
app_set_signer(signer, &pubkey_hex, &sk.to_hex(), key_store::KeyStoreMethod::Seed, false);
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Seed);
settings::settings_load();
shortcuts::shortcuts_load();
let pk = pubkey_hex.clone();
std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); });
true
}
_ => {
eprintln!("[login] CLI login not supported for this method");
false
}
}
}
fn build_context_for_current_user() -> WebContext {
let ctx = web_context::web_context_create();
let pubkey_hex = menu::app_get_pubkey_hex();
let signer = menu::app_get_signer();
let readonly = menu::app_get_readonly();
if !pubkey_hex.is_empty() {
webkit_data::webkit_data_configure(&ctx, &pubkey_hex);
}
nostr_bridge::nostr_bridge_register(&ctx, signer, &pubkey_hex, readonly);
ctx
}
/// Handle a key-press event, dispatching browser-level shortcuts.
/// Mirrors the C version (main.c) with all shortcuts.
fn handle_key_press(_window: &gtk::Window, event: &gdk::EventKey) -> glib::Propagation {
let ctrl = event.state().contains(gdk::ModifierType::CONTROL_MASK);
let shift = event.state().contains(gdk::ModifierType::SHIFT_MASK);
let alt = event.state().contains(gdk::ModifierType::MOD1_MASK);
let key = event.keyval();
// When the active tab is showing a sovereign:// internal page (e.g.
// the settings page's keyboard-shortcut capture), bypass shortcut
// interception so the page's JS can capture arbitrary key combos.
let tabs = tab_manager::tab_manager_get_tabs();
let active_id = tab_manager::tab_manager_get_active_tab();
if let Some(tab) = tabs.iter().find(|t| t.id == active_id) {
if tab.url.starts_with("sovereign://") || tab.url.starts_with("about:settings") {
return glib::Propagation::Proceed;
}
}
// Alt+Left/Right (back/forward) — no modifier mask check needed.
if alt && !ctrl && !shift {
match key {
gdk::keys::constants::Left => {
tab_manager::tab_manager_go_back();
return glib::Propagation::Stop;
}
gdk::keys::constants::Right => {
tab_manager::tab_manager_go_forward();
return glib::Propagation::Stop;
}
_ => {}
}
}
// F-key shortcuts (no modifiers).
if !ctrl && !shift && !alt {
match key {
gdk::keys::constants::F5 => {
tab_manager::tab_manager_reload_active();
return glib::Propagation::Stop;
}
gdk::keys::constants::F11 => {
tab_manager::tab_manager_toggle_fullscreen();
return glib::Propagation::Stop;
}
_ => {}
}
}
// Shift+F5 (force reload).
if shift && !ctrl && !alt && key == gdk::keys::constants::F5 {
if let Some(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) {
wv.reload_bypass_cache();
}
}
return glib::Propagation::Stop;
}
// Ctrl+ key combinations.
if ctrl && !shift && !alt {
match key {
gdk::keys::constants::t => {
if let Some(nb) = tab_manager::tab_manager_get_active_notebook() {
let ctx = build_context_for_current_user();
tab_manager::tab_manager_new_tab(&nb, &ctx, None);
}
return glib::Propagation::Stop;
}
gdk::keys::constants::n => {
tab_manager::tab_manager_new_window_blank();
return glib::Propagation::Stop;
}
gdk::keys::constants::o => {
tab_manager::tab_manager_open_file();
return glib::Propagation::Stop;
}
gdk::keys::constants::w => {
tab_manager::tab_manager_close_active();
return glib::Propagation::Stop;
}
gdk::keys::constants::l => {
tab_manager::tab_manager_focus_url();
return glib::Propagation::Stop;
}
gdk::keys::constants::r => {
tab_manager::tab_manager_reload_active();
return glib::Propagation::Stop;
}
gdk::keys::constants::f => {
if let Some(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) {
// WebKit find controller.
wv.run_javascript("document.body.innerHTML += '<div id=\"sb-find-bar\" style=\"position:fixed;top:0;left:0;right:0;z-index:9999;background:#333;color:#fff;padding:8px;\"><input id=\"sb-find-input\" type=\"text\" placeholder=\"Find in page…\" style=\"width:80%;padding:4px;\"></div>'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {});
}
}
return glib::Propagation::Stop;
}
gdk::keys::constants::d => {
// Bookmark current page.
if let Some(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) {
let url = wv.uri().unwrap_or_default().to_string();
let title = wv.title().unwrap_or_default().to_string();
let _ = db::db_add_bookmark(&title, &url, "");
}
}
return glib::Propagation::Stop;
}
gdk::keys::constants::h => {
tab_manager::tab_manager_open_internal("sovereign://profile");
return glib::Propagation::Stop;
}
gdk::keys::constants::b => {
tab_manager::tab_manager_open_internal("sovereign://bookmarks");
return glib::Propagation::Stop;
}
gdk::keys::constants::j => {
tab_manager::tab_manager_open_internal("sovereign://processes");
return glib::Propagation::Stop;
}
gdk::keys::constants::q => {
session::session_save();
gtk::main_quit();
return glib::Propagation::Stop;
}
gdk::keys::constants::comma => {
tab_manager::tab_manager_open_internal("sovereign://settings");
return glib::Propagation::Stop;
}
gdk::keys::constants::Tab => {
tab_manager::tab_manager_next();
return glib::Propagation::Stop;
}
gdk::keys::constants::Page_Down => {
tab_manager::tab_manager_next();
return glib::Propagation::Stop;
}
gdk::keys::constants::Page_Up => {
tab_manager::tab_manager_prev();
return glib::Propagation::Stop;
}
gdk::keys::constants::plus | gdk::keys::constants::equal => {
tab_manager::tab_manager_zoom_in();
return glib::Propagation::Stop;
}
gdk::keys::constants::minus => {
tab_manager::tab_manager_zoom_out();
return glib::Propagation::Stop;
}
gdk::keys::constants::_0 => {
tab_manager::tab_manager_zoom_reset();
return glib::Propagation::Stop;
}
gdk::keys::constants::Shift_L | gdk::keys::constants::Shift_R => {
return glib::Propagation::Proceed;
}
_ => {}
}
}
// Ctrl+Shift+ key combinations.
if ctrl && shift && !alt {
match key {
gdk::keys::constants::Tab => {
tab_manager::tab_manager_prev();
return glib::Propagation::Stop;
}
gdk::keys::constants::i => {
tab_manager::tab_manager_toggle_inspector();
return glib::Propagation::Stop;
}
gdk::keys::constants::a => {
tab_manager::tab_manager_toggle_sidebar();
return glib::Propagation::Stop;
}
gdk::keys::constants::m => {
tab_manager::tab_manager_toggle_toolbars();
return glib::Propagation::Stop;
}
gdk::keys::constants::u => {
crate::menu::menu_switch_identity();
return glib::Propagation::Stop;
}
gdk::keys::constants::Escape => {
tab_manager::tab_manager_open_internal("sovereign://processes");
return glib::Propagation::Stop;
}
_ => {}
}
}
glib::Propagation::Proceed
}
fn main() {
let args = cli::cli_parse();
if args.help { cli::cli_print_help(); return; }
if args.version { cli::cli_print_version(); return; }
gtk::init().expect("Failed to initialize GTK");
nostr_bridge::nostr_bridge_init();
agent_tools::agent_tools_init();
agent_chat::agent_chat_init();
agent_server::agent_server_init();
agent_login::agent_login_init();
agent_llm::agent_llm_init();
net_services::net_services_init();
tor_control::tor_control_init();
fips_control::fips_control_init();
site_downloader::site_downloader_init();
process_info::process_info_init();
perf_probe::perf_probe_init();
shortcuts::shortcuts_init();
let _ = key_store::key_store_delete_legacy_file();
// Create window but don't show it yet
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_title("sovereign_browser");
window.set_default_size(1200, 800);
window.set_icon_name(Some("web-browser"));
// Show login dialog first (before creating tabs), unless --no-login or
// CLI credentials were provided (--local, --seed, --pubkey, --bunker).
if args.no_login {
println!("[login] No-login mode (browsing without Nostr identity)");
menu::app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true);
} else if args.login_method.is_some() {
// Auto-login from CLI args (no dialog).
if !do_cli_login(&args) {
// CLI login failed — fall back to dialog.
println!("[login] CLI login failed, showing dialog...");
do_login(&window);
}
} else {
println!("[login] Showing login dialog...");
do_login(&window);
}
// Now build the UI. Each tab carries its own toolbar (URL entry,
// back/forward/refresh, bookmark, hamburger menu), so there is no
// shared top-level toolbar.
//
// The window uses a GtkPaned (horizontal) so the agent chat sidebar
// can be toggled on the left side. The notebook goes in the right pane.
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
window.add(&vbox);
let paned = gtk::Paned::new(gtk::Orientation::Horizontal);
vbox.add(&paned);
// Sidebar container (left pane) — hidden by default.
let sidebar_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
sidebar_container.set_size_request(280, -1);
paned.add1(&sidebar_container);
paned.set_position(0);
let notebook = gtk::Notebook::new();
notebook.set_scrollable(false);
notebook.set_show_tabs(true);
notebook.set_show_border(false);
paned.add2(&notebook);
tab_manager::tab_manager_init();
let ctx = build_context_for_current_user();
tab_manager::tab_manager_set_main_window(&window, &notebook);
tab_manager::tab_manager_set_sidebar(&paned, &sidebar_container, &ctx);
tab_manager::tab_manager_apply_theme();
// Create initial tab(s).
// If CLI URLs were provided, open them; otherwise open about:blank.
println!("[browser] Creating initial tab...");
if !args.urls.is_empty() {
for url in &args.urls {
tab_manager::tab_manager_new_tab(&notebook, &ctx, Some(url));
}
} else {
tab_manager::tab_manager_new_tab(&notebook, &ctx, Some("about:blank"));
}
// Restore session if enabled
let browser_settings = settings::settings_get();
if browser_settings.session_restore {
let urls = session::session_restore();
for url in urls {
tab_manager::tab_manager_new_tab(&notebook, &ctx, Some(&url));
}
}
// Wire keyboard shortcuts.
let window_shortcuts = window.clone();
window.connect_key_press_event(move |_w, event| {
handle_key_press(&window_shortcuts, event)
});
window.connect_delete_event(|_, _| {
session::session_save();
gtk::main_quit();
glib::Propagation::Proceed
});
// Show window now that everything is set up.
// The new-tab button is in each tab's toolbar (added by tab_manager_new_tab).
println!("[browser] Showing window...");
window.show_all();
if !args.no_agent {
if let Err(e) = agent_server::agent_server_start() {
eprintln!("[server] Failed to start agent server: {}", e);
}
}
println!("[browser] Starting GTK main loop...");
gtk::main();
}
+181
View File
@@ -0,0 +1,181 @@
//! Menu actions and app state for sovereign_browser
//!
//! Port of the menu proxy functions from `main.c` in the C project.
//! These are called from the hamburger menu in `tab_manager.rs`.
//!
//! This module also owns the app state (signer, pubkey, method) so that
//! both the binary (`main.rs`) and the library modules (`tab_manager.rs`,
//! `menu.rs`) can share it without cross-crate-root references.
use gtk::prelude::*;
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::key_store;
use crate::login_dialog;
use crate::nostr_bridge;
use crate::tab_manager;
/// App state (signer, identity, method).
pub struct AppState {
pub signer: Option<Arc<dyn NostrSigner>>,
pub pubkey_hex: String,
pub privkey_hex: String,
pub method: key_store::KeyStoreMethod,
pub readonly: bool,
}
impl Default for AppState {
fn default() -> Self {
AppState {
signer: None,
pubkey_hex: String::new(),
privkey_hex: String::new(),
method: key_store::KeyStoreMethod::None,
readonly: true,
}
}
}
/// Global app state.
static G_STATE: Lazy<Mutex<AppState>> = Lazy::new(|| Mutex::new(AppState::default()));
/// Set the signer and update dependent modules.
pub fn app_set_signer(
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: &str,
privkey_hex: &str,
method: key_store::KeyStoreMethod,
readonly: bool,
) {
let mut state = G_STATE.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.privkey_hex = privkey_hex.to_string();
state.method = method;
state.readonly = readonly;
nostr_bridge::nostr_bridge_set_signer(state.signer.clone(), &state.pubkey_hex, state.readonly);
crate::agent_login::agent_login_set_signer(state.signer.clone(), &state.pubkey_hex);
crate::settings_sync::settings_sync_init(state.signer.clone(), &state.pubkey_hex);
crate::agent_conversations::agent_conversations_init(state.signer.clone(), &state.pubkey_hex);
crate::agent_skills::agent_skills_init(state.signer.clone(), &state.pubkey_hex);
}
/// Clear the signer and reset app state (used by logout / lock session).
pub fn app_clear_signer() {
let mut state = G_STATE.lock().unwrap();
state.signer = None;
state.pubkey_hex.clear();
state.privkey_hex.clear();
state.method = key_store::KeyStoreMethod::None;
state.readonly = true;
nostr_bridge::nostr_bridge_set_signer(None, "", true);
}
/// Get the current pubkey.
pub fn app_get_pubkey_hex() -> String {
let state = G_STATE.lock().unwrap();
state.pubkey_hex.clone()
}
/// Get the current signer.
pub fn app_get_signer() -> Option<Arc<dyn NostrSigner>> {
let state = G_STATE.lock().unwrap();
state.signer.clone()
}
/// Get the current method.
pub fn app_get_method() -> key_store::KeyStoreMethod {
let state = G_STATE.lock().unwrap();
state.method
}
/// Get whether the session is read-only.
pub fn app_get_readonly() -> bool {
let state = G_STATE.lock().unwrap();
state.readonly
}
/// Run the login dialog and install the resulting identity.
/// Switch to the per-user profile database.
pub fn switch_to_user_db(pubkey_hex: &str) -> i32 {
match crate::db::db_init(pubkey_hex) {
Ok(_) => {
println!("[db] Switched to profile database for {}", pubkey_hex);
0
}
Err(e) => {
eprintln!("[db] Failed to open database: {}", e);
-1
}
}
}
/// Run the login dialog and install the resulting identity.
fn do_login(parent: &gtk::Window) -> bool {
let result = login_dialog::login_dialog_run(parent);
match result {
Some(login_result) => {
let pubkey_hex = login_result.pubkey_hex.clone();
let privkey_hex = login_result.identity.privkey_hex.clone();
let method = login_result.method;
let readonly = login_result.signer.is_none();
if login_result.no_login || pubkey_hex.is_empty() {
println!("[login] No-login mode (browsing without Nostr identity)");
app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true);
crate::settings::settings_load();
crate::shortcuts::shortcuts_load();
return true;
}
if switch_to_user_db(&pubkey_hex) != 0 {
return false;
}
app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly);
let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method);
crate::settings::settings_load();
crate::shortcuts::shortcuts_load();
let pubkey = pubkey_hex.clone();
std::thread::spawn(move || {
crate::relay_fetch::relay_fetch_thread(pubkey);
});
true
}
None => false,
}
}
/// Menu action: switch identity (re-show the login dialog).
pub fn menu_switch_identity() {
if let Some(nb) = tab_manager::tab_manager_get_main_notebook() {
if let Some(win) = nb.toplevel().and_then(|w| w.downcast::<gtk::Window>().ok()) {
if do_login(&win) {
println!("[identity] switched identity");
}
}
}
}
/// Menu action: logout (clear signer, re-show login).
pub fn menu_logout() {
app_clear_signer();
println!("[identity] logged out");
if let Some(nb) = tab_manager::tab_manager_get_main_notebook() {
if let Some(win) = nb.toplevel().and_then(|w| w.downcast::<gtk::Window>().ok()) {
do_login(&win);
}
}
}
/// Menu action: lock session (clear signer, keep identity).
pub fn menu_lock_session() {
app_clear_signer();
println!("[identity] session locked (signer cleared, identity preserved)");
}
+125
View File
@@ -0,0 +1,125 @@
//! Network services management (Tor, FIPS, etc.)
//!
//! Port of `net_services.c` / `net_services.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Network service status.
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum ServiceStatus {
Stopped,
Starting,
Running,
Error(String),
}
/// Network services state.
static G_NET_SERVICES: Lazy<Mutex<NetServicesState>> = Lazy::new(|| Mutex::new(NetServicesState::default()));
struct NetServicesState {
tor_status: ServiceStatus,
fips_status: ServiceStatus,
}
impl Default for NetServicesState {
fn default() -> Self {
NetServicesState {
tor_status: ServiceStatus::Stopped,
fips_status: ServiceStatus::Stopped,
}
}
}
/// Initialize network services.
pub fn net_services_init() {
let mut state = G_NET_SERVICES.lock().unwrap();
state.tor_status = ServiceStatus::Stopped;
state.fips_status = ServiceStatus::Stopped;
}
/// Get Tor status.
pub fn net_services_get_tor_status() -> ServiceStatus {
let state = G_NET_SERVICES.lock().unwrap();
state.tor_status.clone()
}
/// Get FIPS status.
pub fn net_services_get_fips_status() -> ServiceStatus {
let state = G_NET_SERVICES.lock().unwrap();
state.fips_status.clone()
}
/// Start Tor.
pub fn net_services_start_tor() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_NET_SERVICES.lock().unwrap();
state.tor_status = ServiceStatus::Starting;
// TODO: actually start Tor
state.tor_status = ServiceStatus::Running;
println!("[net] Tor started");
Ok(())
}
/// Stop Tor.
pub fn net_services_stop_tor() {
let mut state = G_NET_SERVICES.lock().unwrap();
state.tor_status = ServiceStatus::Stopped;
println!("[net] Tor stopped");
}
/// Start FIPS.
pub fn net_services_start_fips() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_NET_SERVICES.lock().unwrap();
state.fips_status = ServiceStatus::Starting;
// TODO: actually start FIPS
state.fips_status = ServiceStatus::Running;
println!("[net] FIPS started");
Ok(())
}
/// Stop FIPS.
pub fn net_services_stop_fips() {
let mut state = G_NET_SERVICES.lock().unwrap();
state.fips_status = ServiceStatus::Stopped;
println!("[net] FIPS stopped");
}
/// Get the Tor SOCKS proxy endpoint.
///
/// Returns the SOCKS5 endpoint (e.g. "socks5h://127.0.0.1:9050") if Tor
/// is available, or None if Tor is not running. The "socks5h" prefix
/// tells the proxy to perform DNS resolution through Tor (remote DNS),
/// which is required for .onion hostnames.
///
/// Checks both the internal service status AND whether the SOCKS port
/// is actually reachable (e.g. a system Tor daemon started outside the
/// browser).
pub fn net_services_get_tor_socks_endpoint() -> Option<String> {
{
let state = G_NET_SERVICES.lock().unwrap();
if state.tor_status == ServiceStatus::Running {
return Some("socks5h://127.0.0.1:9050".to_string());
}
}
// Check if a system Tor is listening on the default SOCKS port.
if tor_socks_port_available() {
Some("socks5h://127.0.0.1:9050".to_string())
} else {
None
}
}
/// Check if Tor's SOCKS port (9050) is accepting connections.
fn tor_socks_port_available() -> bool {
use std::net::TcpStream;
use std::time::Duration;
match TcpStream::connect_timeout(
&"127.0.0.1:9050".parse().unwrap(),
Duration::from_millis(300),
) {
Ok(_) => true,
Err(_) => false,
}
}
+939
View File
@@ -0,0 +1,939 @@
//! sovereign:// URI scheme handler for window.nostr
//!
//! Port of `nostr_bridge.c` from the C project.
use glib::Bytes;
use gio::MemoryInputStream;
use gio::prelude::InputStreamExtManual;
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use webkit2gtk::*;
use crate::settings;
use crate::db;
static G_BRIDGE: Lazy<Mutex<BridgeState>> = Lazy::new(|| {
Mutex::new(BridgeState {
signer: None,
pubkey_hex: String::new(),
readonly: true,
})
});
struct BridgeState {
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: String,
readonly: bool,
}
pub fn nostr_bridge_init() {}
pub fn nostr_bridge_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str, readonly: bool) {
let mut state = G_BRIDGE.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.readonly = readonly;
}
pub fn nostr_bridge_register(
ctx: &WebContext,
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: &str,
readonly: bool,
) {
nostr_bridge_set_signer(signer, pubkey_hex, readonly);
ctx.register_uri_scheme("sovereign", |request| {
handle_sovereign_scheme(request);
});
}
fn handle_sovereign_scheme(request: &URISchemeRequest) {
let uri = request.uri().unwrap_or_default();
let path = if let Some(stripped) = uri.strip_prefix("sovereign://") {
stripped
} else {
respond_error_json(request, 400, "Invalid URI");
return;
};
// Strip query string for routing (e.g. "profile/data?_=123").
let route = path.split('?').next().unwrap_or(path);
// ── nostr API ───────────────────────────────────────────────────
if route.starts_with("nostr/") {
handle_nostr_api(request, &route[6..]);
return;
}
// ── Settings ───────────────────────────────────────────────────
if route.starts_with("settings/set") {
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
handle_settings_set(request, query);
return;
}
if route == "settings/config" {
handle_settings_config_json(request);
return;
}
// ── Bookmarks REST API ─────────────────────────────────────────
if route.starts_with("bookmarks/") {
handle_bookmarks_api(request, &route[10..]);
return;
}
// ── Processes REST API ─────────────────────────────────────────
if route.starts_with("processes/") {
handle_processes_api(request, &route[10..]);
return;
}
// ── Profile data ───────────────────────────────────────────────
if route == "profile/data" {
handle_profile_data_json(request);
return;
}
// ── QR code generation ─────────────────────────────────────────
if route == "qr" || route.starts_with("qr?") {
handle_qr(request, &path);
return;
}
// ── Security redirects to settings ─────────────────────────────
if route == "security" {
respond_html(request, &crate::embedded_web_content::get_security_html());
return;
}
// ── Embedded assets (HTML, CSS, JS, vendor) ────────────────────
if let Some((content, mime)) = crate::embedded_web_content::get_content_with_mime(route) {
respond_bytes(request, &content, mime);
return;
}
respond_error_json(request, 404, "Not found");
}
/// Handle sovereign://processes/* REST API.
/// Routes: list, tabs, probe-report, tab_probe, tab_action.
fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
let route = sub.split('?').next().unwrap_or(sub);
let query = sub.split_once('?').map(|(_, q)| q).unwrap_or("");
match route {
"list" => {
// Layer 1: OS process list (simplified — just browser + webprocess).
let processes = crate::process_info::process_info_get_all();
respond_json(request, &serde_json::to_string(&processes).unwrap_or_default());
}
"tabs" => {
// Layer 2: per-tab list with probe data.
let tabs = crate::tab_manager::tab_manager_get_tabs();
let tab_infos: Vec<serde_json::Value> = tabs.iter().map(|t| {
serde_json::json!({
"id": t.id,
"title": t.title,
"url": t.url,
"is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"),
})
}).collect();
respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default());
}
"probe-report" => {
// Receive perf probe report from injected JS.
// ?tab_index=N&body=<urlencoded JSON>
let tab_index = extract_query_param(query, "tab_index")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(-1);
let body = extract_query_param(query, "body").unwrap_or_default();
if let Ok(report) = serde_json::from_str::<serde_json::Value>(&body) {
crate::perf_probe::perf_probe_record_report(tab_index, &report);
}
// Return empty JSON (204-like).
respond_json(request, "{}");
}
"tab_probe" => {
// Drill-down for a single tab.
let index = extract_query_param(query, "index")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let report = crate::perf_probe::perf_probe_get_report(index);
respond_json(request, &serde_json::to_string(&report).unwrap_or_default());
}
"tab_action" => {
// ?index=N&action=reload|suspend|close
let _index = extract_query_param(query, "index")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let action = extract_query_param(query, "action").unwrap_or_default();
match action.as_str() {
"reload" => {
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
wv.reload();
}
}
});
}
"close" => {
glib::idle_add_once(move || {
crate::tab_manager::tab_manager_close_active();
});
}
_ => {}
}
respond_json(request, &serde_json::json!({"status": "ok"}).to_string());
}
_ => respond_error_json(request, 404, "Unknown processes route"),
}
}
/// Handle sovereign://bookmarks/* REST API.
/// Mirrors the C version routes: list, add, delete, createdir, deletedir,
/// move, rename, renamedir. Returns JSON (or redirects back to bookmarks).
fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) {
let route = sub.split('?').next().unwrap_or(sub);
let query = sub.split_once('?').map(|(_, q)| q).unwrap_or("");
match route {
"list" => {
let have_signer = !G_BRIDGE.lock().unwrap().readonly;
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
let tree = build_bookmark_tree(&bookmarks);
let json = serde_json::json!({
"have_signer": have_signer,
"tree": tree,
});
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
"add" => {
let dir = extract_query_param(query, "dir").unwrap_or_default();
let url = extract_query_param(query, "url").unwrap_or_default();
let title = extract_query_param(query, "title").unwrap_or_default();
match crate::bookmarks::bookmarks_add(&title, &url, &dir) {
Ok(_) => respond_redirect(request, "sovereign://bookmarks"),
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
"delete" => {
let dir = extract_query_param(query, "dir").unwrap_or_default();
let url = extract_query_param(query, "url").unwrap_or_default();
// Find the bookmark id by url+folder and delete.
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
let found = bookmarks.iter().find(|b| {
b["url"].as_str() == Some(url.as_str())
&& b["folder"].as_str().unwrap_or("") == dir.as_str()
}).and_then(|b| b["id"].as_i64());
match found {
Some(id) => match crate::bookmarks::bookmarks_delete(id) {
Ok(_) => respond_redirect(request, "sovereign://bookmarks"),
Err(e) => respond_error_json(request, 500, &e.to_string()),
},
None => respond_error_json(request, 404, "Bookmark not found"),
}
}
"createdir" => {
// Folders are implicit (folder path on bookmarks). Creating a dir
// is a no-op — the folder appears when a bookmark is added to it.
respond_redirect(request, "sovereign://bookmarks");
}
"deletedir" => {
let dir = extract_query_param(query, "dir").unwrap_or_default();
// Delete all bookmarks under this folder path.
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
let ids: Vec<i64> = bookmarks.iter()
.filter(|b| b["folder"].as_str().unwrap_or("").starts_with(&format!("{}/", dir))
|| b["folder"].as_str().unwrap_or("") == dir.as_str())
.filter_map(|b| b["id"].as_i64())
.collect();
for id in ids {
let _ = crate::bookmarks::bookmarks_delete(id);
}
respond_redirect(request, "sovereign://bookmarks");
}
"rename" => {
let dir = extract_query_param(query, "dir").unwrap_or_default();
let url = extract_query_param(query, "url").unwrap_or_default();
let title = extract_query_param(query, "title").unwrap_or_default();
// Update title via direct DB access.
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
let found = bookmarks.iter().find(|b| {
b["url"].as_str() == Some(url.as_str())
&& b["folder"].as_str().unwrap_or("") == dir.as_str()
}).and_then(|b| b["id"].as_i64());
if let Some(id) = found {
let _ = db::db_update_bookmark_title(id, &title);
}
respond_redirect(request, "sovereign://bookmarks");
}
"renamedir" => {
let old = extract_query_param(query, "old").unwrap_or_default();
let new = extract_query_param(query, "new").unwrap_or_default();
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
for b in &bookmarks {
let folder = b["folder"].as_str().unwrap_or("").to_string();
if let Some(id) = b["id"].as_i64() {
if folder == old || folder.starts_with(&format!("{}/", old)) {
let new_folder = if folder == old {
new.clone()
} else {
format!("{}{}", new, &folder[old.len()..])
};
let _ = db::db_update_bookmark_folder(id, &new_folder);
}
}
}
respond_redirect(request, "sovereign://bookmarks");
}
"move" => {
let from = extract_query_param(query, "from").unwrap_or_default();
let to = extract_query_param(query, "to").unwrap_or_default();
let url = extract_query_param(query, "url").unwrap_or_default();
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
let found = bookmarks.iter().find(|b| {
b["url"].as_str() == Some(url.as_str())
&& b["folder"].as_str().unwrap_or("") == from.as_str()
}).and_then(|b| b["id"].as_i64());
if let Some(id) = found {
let _ = db::db_update_bookmark_folder(id, &to);
}
respond_redirect(request, "sovereign://bookmarks");
}
_ => respond_error_json(request, 404, "Unknown bookmarks route"),
}
}
/// Build the bookmark tree JSON from a flat bookmark list.
/// Shape: {"name": "", "path": "", "bookmarks": [], "children": [...]}
fn build_bookmark_tree(bookmarks: &[serde_json::Value]) -> serde_json::Value {
// Collect distinct folder paths.
let mut folders: Vec<String> = Vec::new();
for b in bookmarks {
let f = b["folder"].as_str().unwrap_or("").to_string();
if !f.is_empty() && !folders.contains(&f) {
folders.push(f);
}
}
// Build a tree of folder path components.
fn node(name: &str, path: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"path": path,
"count": 0,
"bookmarks": [],
"children": [],
})
}
// Root node.
let mut root = node("", "");
let root_bookmarks: Vec<serde_json::Value> = bookmarks.iter()
.filter(|b| b["folder"].as_str().unwrap_or("").is_empty())
.cloned()
.collect();
root["bookmarks"] = serde_json::Value::Array(root_bookmarks);
// Build folder tree.
let mut sorted_folders = folders.clone();
sorted_folders.sort();
for f in &sorted_folders {
let parts: Vec<&str> = f.split('/').collect();
let mut current = &mut root;
let mut path_acc = String::new();
for (i, part) in parts.iter().enumerate() {
if i > 0 { path_acc.push('/'); }
path_acc.push_str(part);
let children = current["children"].as_array_mut().unwrap();
let idx = children.iter().position(|c| c["name"].as_str() == Some(*part));
let child_idx = match idx {
Some(i) => i,
None => {
children.push(node(part, &path_acc));
children.len() - 1
}
};
current = &mut children[child_idx];
}
// Attach bookmarks in this folder.
let fms: Vec<serde_json::Value> = bookmarks.iter()
.filter(|b| b["folder"].as_str().unwrap_or("") == f.as_str())
.cloned()
.collect();
let fms_len = fms.len();
current["bookmarks"] = serde_json::Value::Array(fms);
current["count"] = serde_json::json!(fms_len);
}
root
}
/// Redirect a request to another sovereign:// URL.
fn respond_redirect(request: &URISchemeRequest, target: &str) {
let html = format!(
"<!DOCTYPE html><html><head>\
<meta http-equiv='refresh' content='0; url={}'>\
</head><body>Redirecting…</body></html>",
target
);
respond_html(request, &html);
}
/// Handle sovereign://profile/data — return the user's Nostr profile data.
fn handle_profile_data_json(request: &URISchemeRequest) {
let state = G_BRIDGE.lock().unwrap();
let pubkey_hex = state.pubkey_hex.clone();
let readonly = state.readonly;
let method = crate::menu::app_get_method().as_str().to_string();
drop(state);
// Query kind 0 (profile) events from the DB.
let events = db::db_query_events(&[0], None, None, None, Some(20)).unwrap_or_default();
let kind0 = events.first();
let kind0_present = kind0.is_some();
let kind0_content: serde_json::Value = kind0
.and_then(|e| e.get("content").and_then(|c| c.as_str()))
.and_then(|c| serde_json::from_str(c).ok())
.unwrap_or_else(|| serde_json::json!({}));
// Query kind 3 (contacts) events.
let contacts_events = db::db_query_events(&[3], None, None, None, Some(20)).unwrap_or_default();
let kind3 = contacts_events.first();
let kind3_present = kind3.is_some();
let contact_count = kind3
.and_then(|e| e.get("tags"))
.and_then(|t| t.as_array())
.map(|t| t.len())
.unwrap_or(0);
// Kind 10002 relay list.
let relay_events = db::db_query_events(&[10002], None, None, None, Some(20)).unwrap_or_default();
let kind10002_count = relay_events.len();
let json = serde_json::json!({
"pubkey": pubkey_hex,
"pubkey_short": if pubkey_hex.len() >= 12 {
format!("{}…{}", &pubkey_hex[..8], &pubkey_hex[pubkey_hex.len()-4..])
} else { pubkey_hex.clone() },
"npub": nostr_npub_of_hex(&pubkey_hex),
"method": method,
"signing": if readonly { "read-only" } else { "signing" },
"picture": kind0_content.get("picture").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"name": kind0_content.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"display_name": kind0_content.get("display_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"about": kind0_content.get("about").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"website": kind0_content.get("website").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"lud16": kind0_content.get("lud16").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"nip05": kind0_content.get("nip05").and_then(|v| v.as_str()).unwrap_or("").to_string(),
"kind0_present": kind0_present,
"kind0_created": kind0.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(),
"kind0_count": events.len(),
"kind3_present": kind3_present,
"contact_count": contact_count,
"kind3_created": kind3.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(),
"kind3_count": contacts_events.len(),
"kind10002_count": kind10002_count,
"qr_url": if pubkey_hex.is_empty() { "" } else {
"sovereign://qr?text=npub-placeholder&box_size=6&border=4"
},
});
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
/// Convert a hex pubkey to an npub string (best-effort).
fn nostr_npub_of_hex(hex_str: &str) -> String {
use nostr_core::util::bech32::npub_encode;
let bytes = match hex::decode(hex_str) {
Ok(b) if b.len() == 32 => b,
_ => return String::new(),
};
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
let pk = nostr_core::types::PublicKey::from_bytes(arr);
npub_encode(&pk).unwrap_or_default()
}
/// Handle sovereign://qr?text=... — render a QR code as PNG.
fn handle_qr(request: &URISchemeRequest, full_path: &str) {
let query = full_path.split_once('?').map(|(_, q)| q).unwrap_or("");
let text = extract_query_param(query, "text").unwrap_or_default();
if text.is_empty() {
respond_error_json(request, 400, "Missing text parameter");
return;
}
match crate::qr::qr_generate_png(&text) {
Ok(png) => respond_bytes_raw(request, png, "image/png"),
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
/// Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y).
/// Mirrors the C version (nostr_bridge.c:1036).
fn handle_settings_set(request: &URISchemeRequest, query: &str) {
// Toggle path: ?feature=X
if let Some(feature) = extract_query_param(query, "feature") {
let (new_state, reload) = match feature.as_str() {
"restore_session" => {
let mut s = settings::settings_get();
s.session_restore = !s.session_restore;
settings::settings_update(&serde_json::json!({"session_restore": s.session_restore}));
settings::settings_save();
(s.session_restore, false)
}
"show_tab_close_buttons" => {
let mut s = settings::settings_get();
s.show_tab_close_buttons = !s.show_tab_close_buttons;
settings::settings_update(&serde_json::json!({"show_tab_close_buttons": s.show_tab_close_buttons}));
settings::settings_save();
(s.show_tab_close_buttons, false)
}
"middle_click_close" => {
let mut s = settings::settings_get();
s.middle_click_close = !s.middle_click_close;
settings::settings_update(&serde_json::json!({"middle_click_close": s.middle_click_close}));
settings::settings_save();
(s.middle_click_close, false)
}
"ctrl_tab_switch" => {
let mut s = settings::settings_get();
s.ctrl_tab_switch = !s.ctrl_tab_switch;
settings::settings_update(&serde_json::json!({"ctrl_tab_switch": s.ctrl_tab_switch}));
settings::settings_save();
(s.ctrl_tab_switch, false)
}
"tab_drag_reorder" => {
let mut s = settings::settings_get();
s.tab_drag_reorder = !s.tab_drag_reorder;
settings::settings_update(&serde_json::json!({"tab_drag_reorder": s.tab_drag_reorder}));
settings::settings_save();
(s.tab_drag_reorder, false)
}
"theme_dark" => {
let mut s = settings::settings_get();
s.theme_dark = !s.theme_dark;
settings::settings_update(&serde_json::json!({"theme_dark": s.theme_dark}));
settings::settings_save();
(s.theme_dark, false)
}
"agent_server_enabled" => {
// Toggle agent server enable state. Stored in DB directly.
let current = db::db_kv_get("agent_server_enabled").ok().flatten().unwrap_or_else(|| "true".to_string());
let new = if current == "true" { "false" } else { "true" };
let _ = db::db_kv_set("agent_server_enabled", new);
(new == "true", true)
}
_ => {
respond_error_json(request, 400, "Unknown feature");
return;
}
};
println!("[settings] {}: {}", feature, if new_state { "ON" } else { "OFF" });
let mut result = serde_json::json!({ "key": feature, "value": new_state });
if reload { result["reload"] = serde_json::json!(true); }
respond_json(request, &serde_json::to_string(&result).unwrap_or_default());
return;
}
// Key/value path: ?key=X&value=Y
let key = match extract_query_param(query, "key") {
Some(k) => k,
None => {
respond_error_json(request, 400, "Missing key parameter");
return;
}
};
let value = extract_query_param(query, "value").unwrap_or_default();
// Handle shortcut pseudo-keys.
if key == "shortcut.reset_all" {
crate::shortcuts::shortcuts_init();
crate::shortcuts::shortcuts_save();
println!("[settings] shortcut.reset_all");
respond_json(request, &serde_json::json!({"key": "shortcut.reset_all", "value": "defaults"}).to_string());
return;
}
if key == "shortcut.reset" {
// Reset a single shortcut by action.
if let Some(action) = extract_query_param(query, "action") {
crate::shortcuts::shortcuts_init();
crate::shortcuts::shortcuts_save();
println!("[settings] shortcut.reset {}", action);
respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string());
return;
}
}
if key.starts_with("shortcut.") {
// Set a shortcut: key="shortcut.new_tab", value="<Control>t"
// Our Shortcut model uses modifiers+key. For now, reinitialize.
crate::shortcuts::shortcuts_init();
crate::shortcuts::shortcuts_save();
println!("[settings] shortcut set: {} = {}", key, value);
respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string());
return;
}
let reload = match key.as_str() {
"new_tab_url" => {
settings::settings_update(&serde_json::json!({ "new_tab_url": value }));
settings::settings_save();
false
}
"tab_bar_position" => {
let pos = match value.as_str() {
"top" => 0,
"bottom" => 1,
"left" => 2,
"right" => 3,
_ => { respond_error_json(request, 400, "Invalid position value"); return; }
};
settings::settings_update(&serde_json::json!({ "tab_bar_position": pos }));
settings::settings_save();
false
}
"max_tabs" => {
let v: i32 = value.parse().unwrap_or(1).max(1);
settings::settings_update(&serde_json::json!({ "max_tabs": v }));
settings::settings_save();
false
}
"agent_server_port" => {
let v: i32 = value.parse().unwrap_or(17777);
if v < 1 || v > 65535 { respond_error_json(request, 400, "Port out of range (1-65535)"); return; }
settings::settings_update(&serde_json::json!({ "agent_port": v }));
settings::settings_save();
true
}
"agent_allowed_origins" => {
let _ = db::db_kv_set("agent_allowed_origins", &value);
false
}
"agent_login_timeout_ms" => {
let v: i64 = value.parse().unwrap_or(0).max(0);
let _ = db::db_kv_set("agent_login_timeout_ms", &v.to_string());
false
}
"bootstrap_relays" => {
settings::settings_update(&serde_json::json!({ "bootstrap_relays": value }));
settings::settings_save();
false
}
"search_engine" => {
let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave");
if !valid { respond_error_json(request, 400, "Unknown search engine"); return; }
settings::settings_update(&serde_json::json!({ "search_engine": value }));
settings::settings_save();
false
}
_ => { respond_error_json(request, 400, "Unknown key"); return; }
};
let mut result = serde_json::json!({ "key": key, "value": value });
if reload { result["reload"] = serde_json::json!(true); }
respond_json(request, &serde_json::to_string(&result).unwrap_or_default());
}
/// Handle sovereign://settings/config — return all settings as JSON.
/// Mirrors the C version (nostr_bridge.c:2940).
fn handle_settings_config_json(request: &URISchemeRequest) {
let s = settings::settings_get();
let pos_str = match s.tab_bar_position {
0 => "top",
1 => "bottom",
2 => "left",
3 => "right",
_ => "top",
};
// Search engine list.
let search_engines = serde_json::json!([
{"id": "duckduckgo", "name": "DuckDuckGo"},
{"id": "google", "name": "Google"},
{"id": "brave", "name": "Brave"},
]);
// Keyboard shortcuts.
let all_shortcuts = crate::shortcuts::shortcuts_get_all();
let shortcuts: Vec<serde_json::Value> = all_shortcuts.iter().map(|sc| {
let accel = format!("<{}>{}", sc.modifiers, sc.key);
serde_json::json!({
"id": sc.action,
"label": sc.action,
"accel": accel,
})
}).collect();
let json = serde_json::json!({
"theme_dark": s.theme_dark,
"restore_session": s.session_restore,
"new_tab_url": s.new_tab_url,
"tab_bar_position": pos_str,
"show_tab_close_buttons": s.show_tab_close_buttons,
"middle_click_close": s.middle_click_close,
"ctrl_tab_switch": s.ctrl_tab_switch,
"tab_drag_reorder": s.tab_drag_reorder,
"max_tabs": s.max_tabs,
"agent_server_enabled": true,
"agent_server_port": s.agent_port,
"agent_allowed_origins": "",
"agent_login_timeout_ms": 30000,
"bootstrap_relays": s.bootstrap_relays,
"search_engine": s.search_engine,
"dev_extras": true,
"file_access": true,
"universal_access": true,
"search_engines": search_engines,
"shortcuts": shortcuts,
});
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
fn handle_nostr_api(request: &URISchemeRequest, method: &str) {
// The method may have a query string (e.g. "signEvent?body=...").
// Split it off.
let (method_name, query) = method.split_once('?').unwrap_or((method, ""));
let body_param = extract_query_param(query, "body");
match method_name {
"getPublicKey" => {
let state = G_BRIDGE.lock().unwrap();
let json = serde_json::json!({ "pubkey": state.pubkey_hex });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
"signEvent" => {
let event_json = match body_param {
Some(b) => b,
None => match read_request_body(request) {
Ok(b) => b,
Err(_) => {
respond_error_json(request, 400, "Invalid request body");
return;
}
},
};
let state = G_BRIDGE.lock().unwrap();
if state.readonly {
respond_error_json(request, 403, "Read-only mode");
return;
}
if let Some(ref signer) = state.signer {
match serde_json::from_str::<nostr_core::types::Event>(&event_json) {
Ok(event) => {
match signer.sign_event(&event) {
Ok(signed) => {
let result = serde_json::to_string(&signed).unwrap_or_default();
respond_json(request, &result);
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
Err(e) => respond_error_json(request, 400, &e.to_string()),
}
} else {
respond_error_json(request, 500, "No signer available");
}
}
"getRelays" => {
let relays = settings::settings_get_bootstrap_relays();
let json = serde_json::json!({ "relays": relays });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
"nip04Encrypt" | "nip04Decrypt" | "nip44Encrypt" | "nip44Decrypt" => {
let body_json = match &body_param {
Some(b) => b.clone(),
None => match read_request_body(request) {
Ok(b) => b,
Err(_) => {
respond_error_json(request, 400, "Invalid request body");
return;
}
},
};
let params: serde_json::Value = match serde_json::from_str(&body_json) {
Ok(v) => v,
Err(e) => {
respond_error_json(request, 400, &format!("Invalid JSON: {}", e));
return;
}
};
let peer_pubkey_hex = match params.get("peer_pubkey").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
respond_error_json(request, 400, "Missing peer_pubkey");
return;
}
};
let state = G_BRIDGE.lock().unwrap();
if state.readonly {
respond_error_json(request, 403, "Read-only mode");
return;
}
let signer = match &state.signer {
Some(s) => s.clone(),
None => {
respond_error_json(request, 500, "No signer available");
return;
}
};
drop(state);
// Parse the peer pubkey.
let peer_pk = match parse_pubkey_hex(&peer_pubkey_hex) {
Some(pk) => pk,
None => {
respond_error_json(request, 400, "Invalid peer_pubkey");
return;
}
};
match method_name {
"nip04Encrypt" => {
let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or("");
match signer.nip04_encrypt(&peer_pk, plaintext) {
Ok(ciphertext) => {
let json = serde_json::json!({ "result": ciphertext });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
"nip04Decrypt" => {
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str()).unwrap_or("");
match signer.nip04_decrypt(&peer_pk, ciphertext) {
Ok(plaintext) => {
let json = serde_json::json!({ "result": plaintext });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
"nip44Encrypt" => {
let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or("");
match signer.nip44_encrypt(&peer_pk, plaintext) {
Ok(ciphertext) => {
let json = serde_json::json!({ "result": ciphertext });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
"nip44Decrypt" => {
let ciphertext_bytes: Vec<u8> = params.get("ciphertext").map(|v| {
if let Some(s) = v.as_str() {
s.as_bytes().to_vec()
} else if let Some(arr) = v.as_array() {
arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()
} else {
Vec::new()
}
}).unwrap_or_default();
match signer.nip44_decrypt(&peer_pk, &ciphertext_bytes) {
Ok(plaintext) => {
let json = serde_json::json!({ "result": plaintext });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
_ => unreachable!(),
}
}
_ => respond_error_json(request, 404, "Unknown method"),
}
}
/// Extract a query parameter value from a query string (e.g. "body=...&foo=bar").
fn extract_query_param(query: &str, key: &str) -> Option<String> {
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
if k == key {
// URL-decode the value.
return Some(url_decode(v));
}
}
}
None
}
/// Simple URL percent-decoding.
fn url_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let h1 = chars.next();
let h2 = chars.next();
if let (Some(a), Some(b)) = (h1, h2) {
if let Ok(byte) = u8::from_str_radix(&format!("{}{}", a, b), 16) {
result.push(byte as char);
continue;
}
}
result.push(c);
} else if c == '+' {
result.push(' ');
} else {
result.push(c);
}
}
result
}
/// Parse a hex pubkey string into a PublicKey.
fn parse_pubkey_hex(hex_str: &str) -> Option<nostr_core::types::PublicKey> {
let bytes = hex::decode(hex_str).ok()?;
if bytes.len() != 32 {
return None;
}
let arr: [u8; 32] = bytes.try_into().ok()?;
Some(nostr_core::types::PublicKey::from_bytes(arr))
}
fn respond_json(request: &URISchemeRequest, json: &str) {
let body = json.to_string().into_bytes();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("application/json"));
}
fn respond_bytes(request: &URISchemeRequest, content: &str, mime: &str) {
let body = content.as_bytes().to_vec();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some(mime));
}
fn respond_bytes_raw(request: &URISchemeRequest, body: Vec<u8>, mime: &str) {
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some(mime));
}
fn respond_html(request: &URISchemeRequest, html: &str) {
let body = html.to_string().into_bytes();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("text/html"));
}
fn respond_error_json(request: &URISchemeRequest, _code: i32, message: &str) {
let json = serde_json::json!({ "error": message });
let body = serde_json::to_string(&json).unwrap_or_default().into_bytes();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("application/json"));
}
fn read_request_body(request: &URISchemeRequest) -> Result<String, ()> {
if let Some(body_stream) = request.http_body() {
let mut buf = vec![0u8; 65536];
let cancellable: Option<&gio::Cancellable> = None;
if body_stream.read_all(&mut buf, cancellable).is_ok() {
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
buf.truncate(len);
return String::from_utf8(buf).map_err(|_| ());
}
}
Err(())
}
+211
View File
@@ -0,0 +1,211 @@
//! window.nostr JS injection for sovereign_browser
//!
//! Port of `nostr_inject.c` / `nostr_inject.h` from the C project.
use webkit2gtk::*;
/// The NIP-07 JavaScript shim that bridges window.nostr calls to the
/// sovereign:// URI scheme.
///
/// Mirrors the C version (nostr_inject.c). Key design decisions:
///
/// 1. **Synchronous XMLHttpRequest** — WebKitGTK's `fetch()` and async XHR
/// enforce CORS on custom schemes even when registered as secure.
/// Synchronous XHR to a secure custom scheme works without CORS headers.
/// The bridge is local (sovereign:// URI scheme handler in Rust), so the
/// call is effectively instant.
///
/// 2. **Body as query parameter** — WebKitGTK's custom scheme handler doesn't
/// easily expose POST bodies, so we URL-encode the body as `?body=<json>`.
///
/// 3. **Object.defineProperty with a named constructor** — Libraries like
/// nostr-lite.js check `window.nostr.constructor.name` to detect "real
/// extensions". A plain object has constructor name "Object" which fails
/// the check. We create a named constructor function so the constructor
/// name is "NostrExtension", making it look like a real extension.
const NOSTR_SHIM_JS: &str = r#"
(function() {
'use strict';
var BRIDGE_BASE = 'sovereign://nostr/';
function bridgeCall(method, bodyObj) {
var url = BRIDGE_BASE + method;
if (bodyObj !== undefined && bodyObj !== null) {
/* Encode the body as a query parameter since WebKitGTK's custom
* scheme handler doesn't easily expose POST bodies. */
var bodyJson = JSON.stringify(bodyObj);
url += '?body=' + encodeURIComponent(bodyJson);
}
/* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR
* enforce CORS on custom schemes even when registered as secure, but
* synchronous XHR to a secure custom scheme works without CORS headers.
* The bridge is local (sovereign:// URI scheme handler in Rust), so the
* call is effectively instant. */
return new Promise(function(resolve, reject) {
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, false); /* synchronous */
xhr.send();
var text = xhr.responseText;
if (!text) {
reject(new Error('Bridge returned empty response'));
return;
}
var data;
try {
data = JSON.parse(text);
} catch(e) {
reject(new Error('Bridge returned invalid JSON: ' + text));
return;
}
if (data.error !== undefined) {
var msg = data.message || ('Error code ' + data.error);
reject(new Error(msg));
return;
}
resolve(data);
} catch(e) {
reject(new Error('Bridge request failed: ' + e.message));
}
});
}
/* NIP-07 window.nostr API.
*
* We use a named constructor function so that window.nostr.constructor.name
* returns "NostrExtension" instead of "Object". Some libraries (e.g.
* nostr-lite.js) check the constructor name to detect "real extensions"
* vs plain object shims. */
function NostrExtension() {
this.getPublicKey = function() {
return bridgeCall('getPublicKey').then(function(d) { return d.pubkey; });
};
this.signEvent = function(event) {
return bridgeCall('signEvent', event).then(function(d) { return d; });
};
this.getRelays = function() {
return bridgeCall('getRelays').then(function(d) { return d.relays; });
};
this.nip04 = {
encrypt: function(peerPubkey, plaintext) {
return bridgeCall('nip04Encrypt', {
peer_pubkey: peerPubkey,
plaintext: plaintext
}).then(function(d) { return d.result; });
},
decrypt: function(peerPubkey, ciphertext) {
return bridgeCall('nip04Decrypt', {
peer_pubkey: peerPubkey,
ciphertext: ciphertext
}).then(function(d) { return d.result; });
}
};
this.nip44 = {
encrypt: function(peerPubkey, plaintext) {
return bridgeCall('nip44Encrypt', {
peer_pubkey: peerPubkey,
plaintext: plaintext
}).then(function(d) { return d.result; });
},
decrypt: function(peerPubkey, ciphertext) {
return bridgeCall('nip44Decrypt', {
peer_pubkey: peerPubkey,
ciphertext: ciphertext
}).then(function(d) { return d.result; });
}
};
}
/* Only install if no real extension is present. */
if (window.nostr === undefined || window.nostr === null) {
var nostr = new NostrExtension();
Object.defineProperty(window, 'nostr', {
value: nostr,
writable: false,
configurable: false,
enumerable: true
});
}
})();
"#;
/// Perf probe preamble — skips sovereign:// and about: pages, sets tab index.
/// The tab index is baked in at injection time as a literal.
fn perf_probe_preamble(tab_index: i32) -> String {
format!(
r#"(function(){{
'use strict';
try {{
var u = location.href || '';
if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {{
window.__sbPerfProbeSkip = true;
}} else {{
window.__sbPerfProbeSkip = false;
window.__sbPerfTabIndex = {};
}}
}} catch(e) {{}}
}})();
"#,
tab_index
)
}
/// The perf-probe.js script (embedded from www/js/perf-probe.js).
const PERF_PROBE_JS: &str = include_str!("../www/js/perf-probe.js");
/// Create a UserContentManager with the NIP-07 shim and perf probe injected.
/// The perf probe is injected with a preamble that skips sovereign:// pages.
/// The tab_index is used by the probe to identify which tab is reporting.
pub fn nostr_inject_create_content_manager() -> UserContentManager {
let manager = UserContentManager::new();
let script = UserScript::new(
NOSTR_SHIM_JS,
UserContentInjectedFrames::AllFrames,
UserScriptInjectionTime::Start,
&[], // allow_list
&[], // block_list
);
manager.add_script(&script);
manager
}
/// Create a UserContentManager with the NIP-07 shim and perf probe injected,
/// with the given tab index baked into the preamble.
pub fn nostr_inject_create_content_manager_with_probe(tab_index: i32) -> UserContentManager {
let manager = UserContentManager::new();
// NIP-07 shim.
let script = UserScript::new(
NOSTR_SHIM_JS,
UserContentInjectedFrames::AllFrames,
UserScriptInjectionTime::Start,
&[], // allow_list
&[], // block_list
);
manager.add_script(&script);
// Perf probe preamble (skips sovereign:// pages, sets tab index).
let preamble = UserScript::new(
&perf_probe_preamble(tab_index),
UserContentInjectedFrames::AllFrames,
UserScriptInjectionTime::Start,
&[],
&[],
);
manager.add_script(&preamble);
// Perf probe body.
let probe = UserScript::new(
PERF_PROBE_JS,
UserContentInjectedFrames::AllFrames,
UserScriptInjectionTime::Start,
&[],
&[],
);
manager.add_script(&probe);
manager
}
+43
View File
@@ -0,0 +1,43 @@
//! WebKitGTK nostr:// URI scheme handler
//!
//! Port of `nostr_scheme.c` / `nostr_scheme.h` from the C project.
use webkit2gtk::*;
use gio::MemoryInputStream;
use crate::nostr_url;
pub fn nostr_scheme_register(ctx: &WebContext) {
ctx.register_uri_scheme("nostr", |request| {
handle_nostr_scheme(request);
});
}
fn handle_nostr_scheme(request: &URISchemeRequest) {
let uri = request.uri().unwrap_or_default();
let entity_str = nostr_url::nostr_url_entity(&uri).unwrap_or(&uri);
match nostr_url::nostr_url_decode(entity_str) {
Ok(entity) => {
let html = format!(
r#"<!DOCTYPE html><html><head><title>Nostr Entity</title></head><body>
<h1>Nostr Entity</h1><p>Type: {}</p>{}{}{}{}{}</body></html>"#,
entity.entity_type,
entity.pubkey.map(|pk| format!("<p>Pubkey: <code>{}</code></p>", hex::encode(pk))).unwrap_or_default(),
entity.event_id.map(|eid| format!("<p>Event ID: <code>{}</code></p>", hex::encode(eid))).unwrap_or_default(),
entity.kind.map(|k| format!("<p>Kind: {}</p>", k)).unwrap_or_default(),
entity.identifier.as_ref().map(|id| format!("<p>Identifier: <code>{}</code></p>", id)).unwrap_or_default(),
entity.relay_url.as_ref().map(|url| format!("<p>Relay: <code>{}</code></p>", url)).unwrap_or_default(),
);
let body = html.into_bytes();
let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("text/html"));
}
Err(_) => {
let html = format!("<html><body><h1>Nostr URI</h1><p>Raw: <code>{}</code></p></body></html>", entity_str);
let body = html.into_bytes();
let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("text/html"));
}
}
}
+152
View File
@@ -0,0 +1,152 @@
//! NIP-19 decoding and Nostr URI normalization.
//!
//! Port of `nostr_url.h` / `nostr_url.c` from the C project.
use nostr_core::NostrResult;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NostrEntityType {
None,
Npub,
Nsec,
Note,
Nevent,
Naddr,
Nprofile,
Nrelay,
}
impl fmt::Display for NostrEntityType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NostrEntityType::None => write!(f, "none"),
NostrEntityType::Npub => write!(f, "npub"),
NostrEntityType::Nsec => write!(f, "nsec"),
NostrEntityType::Note => write!(f, "note"),
NostrEntityType::Nevent => write!(f, "nevent"),
NostrEntityType::Naddr => write!(f, "naddr"),
NostrEntityType::Nprofile => write!(f, "nprofile"),
NostrEntityType::Nrelay => write!(f, "nrelay"),
}
}
}
#[derive(Debug, Clone)]
pub struct NostrDecodedEntity {
pub entity_type: NostrEntityType,
pub pubkey: Option<[u8; 32]>,
pub event_id: Option<[u8; 32]>,
pub kind: Option<u32>,
pub identifier: Option<String>,
pub relay_url: Option<String>,
pub relay_hints: Vec<String>,
}
impl Default for NostrDecodedEntity {
fn default() -> Self {
NostrDecodedEntity {
entity_type: NostrEntityType::None,
pubkey: None,
event_id: None,
kind: None,
identifier: None,
relay_url: None,
relay_hints: Vec::new(),
}
}
}
pub fn nostr_url_detect(input: &str) -> NostrEntityType {
if input.starts_with("npub") { NostrEntityType::Npub }
else if input.starts_with("nsec") { NostrEntityType::Nsec }
else if input.starts_with("note") { NostrEntityType::Note }
else if input.starts_with("nevent") { NostrEntityType::Nevent }
else if input.starts_with("naddr") { NostrEntityType::Naddr }
else if input.starts_with("nprofile") { NostrEntityType::Nprofile }
else if input.starts_with("nrelay") { NostrEntityType::Nrelay }
else { NostrEntityType::None }
}
pub fn nostr_url_decode(input: &str) -> NostrResult<NostrDecodedEntity> {
let entity_type = nostr_url_detect(input);
if entity_type == NostrEntityType::None {
return Err(nostr_core::NostrError::InvalidInput);
}
// bech32 0.11.1 decode returns (Hrp, Vec<u8>)
let (_hrp, bytes): (bech32::Hrp, Vec<u8>) = bech32::decode(input)
.map_err(|_| nostr_core::NostrError::InvalidInput)?;
let mut entity = NostrDecodedEntity {
entity_type,
..Default::default()
};
match entity_type {
NostrEntityType::Npub | NostrEntityType::Nsec => {
if bytes.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
entity.pubkey = Some(arr);
}
}
NostrEntityType::Note => {
if bytes.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
entity.event_id = Some(arr);
}
}
NostrEntityType::Nevent | NostrEntityType::Nprofile | NostrEntityType::Naddr => {
let mut i = 0;
while i < bytes.len() {
if i + 1 >= bytes.len() { break; }
let tlv_type = bytes[i];
let tlv_len = bytes[i + 1] as usize;
i += 2;
if i + tlv_len > bytes.len() { break; }
let tlv_value = &bytes[i..i + tlv_len];
i += tlv_len;
match tlv_type {
0 => {
if tlv_value.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(tlv_value);
if entity_type == NostrEntityType::Nevent {
entity.event_id = Some(arr);
} else {
entity.pubkey = Some(arr);
}
}
}
1 => { if let Ok(s) = String::from_utf8(tlv_value.to_vec()) { entity.relay_hints.push(s); } }
2 => { if entity_type == NostrEntityType::Naddr { if let Ok(s) = String::from_utf8(tlv_value.to_vec()) { entity.identifier = Some(s); } } }
3 => { if tlv_value.len() == 4 { let mut arr = [0u8; 4]; arr.copy_from_slice(tlv_value); entity.kind = Some(u32::from_be_bytes(arr)); } }
_ => {}
}
}
}
NostrEntityType::Nrelay => {
if let Ok(s) = String::from_utf8(bytes) { entity.relay_url = Some(s); }
}
NostrEntityType::None => {}
}
Ok(entity)
}
pub fn nostr_url_normalize(input: &str) -> String {
if let Ok(entity) = nostr_url_decode(input) {
if let Some(pk) = entity.pubkey { return hex::encode(pk); }
}
input.to_string()
}
pub fn nostr_url_entity(input: &str) -> Option<&str> {
if let Some(stripped) = input.strip_prefix("nostr:") { Some(stripped) } else { Some(input) }
}
pub fn nostr_hex_encode32(data: &[u8; 32]) -> String {
hex::encode(data)
}
+106
View File
@@ -0,0 +1,106 @@
//! Performance probing and monitoring
//!
//! Port of `perf_probe.c` / `perf_probe.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
use std::time::Instant;
/// Performance metrics.
#[derive(Debug, Clone, serde::Serialize)]
pub struct PerfMetrics {
pub page_load_times: Vec<f64>,
pub memory_usage_mb: f64,
pub cpu_usage_percent: f64,
pub tab_count: usize,
}
/// Performance probe state.
static G_PERF_PROBE: Lazy<Mutex<PerfProbeState>> = Lazy::new(|| Mutex::new(PerfProbeState::default()));
struct PerfProbeState {
page_load_start: Option<Instant>,
page_load_times: Vec<f64>,
}
impl Default for PerfProbeState {
fn default() -> Self {
PerfProbeState {
page_load_start: None,
page_load_times: Vec::new(),
}
}
}
/// Initialize performance probing.
pub fn perf_probe_init() {
let mut state = G_PERF_PROBE.lock().unwrap();
state.page_load_start = None;
state.page_load_times.clear();
}
/// Mark the start of a page load.
pub fn perf_probe_page_load_start() {
let mut state = G_PERF_PROBE.lock().unwrap();
state.page_load_start = Some(Instant::now());
}
/// Mark the end of a page load and record the time.
pub fn perf_probe_page_load_end() {
let mut state = G_PERF_PROBE.lock().unwrap();
if let Some(start) = state.page_load_start {
let elapsed = start.elapsed().as_secs_f64();
state.page_load_times.push(elapsed);
if state.page_load_times.len() > 100 {
state.page_load_times.remove(0);
}
}
state.page_load_start = None;
}
/// Per-tab probe report received from the injected perf-probe.js.
static G_PROBE_REPORTS: Lazy<Mutex<Vec<ProbeReport>>> = Lazy::new(|| Mutex::new(Vec::new()));
/// A probe report from a single tab.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProbeReport {
pub tab_index: i32,
pub cpu_busy_percent: f64,
pub fps: f64,
pub timer_count: u32,
pub net_inflight: u32,
pub heap_used: f64,
pub event_listener_count: u32,
pub worker_count: u32,
pub dom_node_count: u32,
}
/// Record a probe report from a tab.
pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) {
let mut reports = G_PROBE_REPORTS.lock().unwrap();
// Update or insert the report for this tab index.
if let Some(existing) = reports.iter_mut().find(|r| r.tab_index == tab_index) {
if let Ok(parsed) = serde_json::from_value::<ProbeReport>(report.clone()) {
*existing = parsed;
}
} else if let Ok(parsed) = serde_json::from_value::<ProbeReport>(report.clone()) {
reports.push(parsed);
}
}
/// Get the probe report for a specific tab index.
pub fn perf_probe_get_report(index: usize) -> Option<ProbeReport> {
let reports = G_PROBE_REPORTS.lock().unwrap();
reports.iter().find(|r| r.tab_index as usize == index).cloned()
}
/// Get current performance metrics.
pub fn perf_probe_get_metrics() -> PerfMetrics {
let state = G_PERF_PROBE.lock().unwrap();
PerfMetrics {
page_load_times: state.page_load_times.clone(),
memory_usage_mb: 0.0, // TODO: get actual memory usage
cpu_usage_percent: 0.0, // TODO: get actual CPU usage
tab_count: crate::tab_manager::tab_manager_get_count(),
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Process information for the processes page
//!
//! Port of `process_info.c` / `process_info.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Process info state.
static G_PROCESS_INFO: Lazy<Mutex<ProcessInfoState>> = Lazy::new(|| Mutex::new(ProcessInfoState::default()));
struct ProcessInfoState {
web_processes: Vec<WebProcessInfo>,
}
/// Information about a web process.
#[derive(Debug, Clone, serde::Serialize)]
pub struct WebProcessInfo {
pub pid: u32,
pub url: String,
pub title: String,
pub memory_mb: f64,
pub cpu_percent: f64,
}
impl Default for ProcessInfoState {
fn default() -> Self {
ProcessInfoState {
web_processes: Vec::new(),
}
}
}
/// Initialize process info.
pub fn process_info_init() {
let mut state = G_PROCESS_INFO.lock().unwrap();
state.web_processes.clear();
}
/// Get all web processes.
pub fn process_info_get_all() -> Vec<WebProcessInfo> {
let state = G_PROCESS_INFO.lock().unwrap();
state.web_processes.clone()
}
/// Add a web process.
pub fn process_info_add(pid: u32, url: &str, title: &str) {
let mut state = G_PROCESS_INFO.lock().unwrap();
state.web_processes.push(WebProcessInfo {
pid,
url: url.to_string(),
title: title.to_string(),
memory_mb: 0.0,
cpu_percent: 0.0,
});
}
/// Remove a web process.
pub fn process_info_remove(pid: u32) {
let mut state = G_PROCESS_INFO.lock().unwrap();
state.web_processes.retain(|p| p.pid != pid);
}
+47
View File
@@ -0,0 +1,47 @@
//! Per-user profile management
//!
//! Port of `profile.c` / `profile.h` from the C project.
use std::path::PathBuf;
/// Get the profiles directory.
pub fn profile_get_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser").join("profiles")
}
/// Get the profile directory for a given pubkey.
pub fn profile_get_profile_dir(pubkey_hex: &str) -> PathBuf {
profile_get_dir().join(pubkey_hex)
}
/// List all profiles.
pub fn profile_list() -> Vec<String> {
let dir = profile_get_dir();
if !dir.exists() {
return Vec::new();
}
let mut profiles = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if let Some(name) = entry.file_name().to_str() {
profiles.push(name.to_string());
}
}
}
}
profiles
}
/// Check if a profile exists.
pub fn profile_exists(pubkey_hex: &str) -> bool {
profile_get_profile_dir(pubkey_hex).exists()
}
/// Create a profile directory.
pub fn profile_create(pubkey_hex: &str) -> std::io::Result<()> {
let dir = profile_get_profile_dir(pubkey_hex);
std::fs::create_dir_all(dir)
}
+29
View File
@@ -0,0 +1,29 @@
//! QR code generation for sovereign_browser
//!
//! Port of `qr.c` / `qr.h` from the C project.
use qrcode::QrCode;
use qrcode::render::svg;
/// Generate an SVG QR code from a string.
pub fn qr_generate_svg(data: &str) -> Result<String, Box<dyn std::error::Error>> {
let code = QrCode::new(data)?;
let svg = code.render()
.min_dimensions(200, 200)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#ffffff"))
.build();
Ok(svg)
}
/// Generate a PNG QR code as bytes.
pub fn qr_generate_png(data: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let code = QrCode::new(data)?;
let image = code.render::<image::Luma<u8>>()
.min_dimensions(200, 200)
.build();
let mut buf = std::io::Cursor::new(Vec::new());
image.write_to(&mut buf, image::ImageFormat::Png)?;
Ok(buf.into_inner())
}
+134
View File
@@ -0,0 +1,134 @@
//! Post-login bootstrap relay fetch for sovereign_browser
//!
//! Port of `relay_fetch.c` / `relay_fetch.h` from the C project.
//! Uses `nostr_relay` from `rust_core_lib`.
use nostr_core::types::Filter;
use nostr_relay::pool::ReconnectConfig;
use tokio::runtime::Runtime;
use crate::db;
use crate::settings;
/// Maximum number of relays to parse from the bootstrap_relays setting.
const MAX_RELAYS: usize = 32;
/// Relay query timeout in seconds.
const RELAY_TIMEOUT_MS: u64 = 15000;
/// Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays.
///
/// Filters out .onion relay URLs when Tor is not running, since
/// tokio_tungstenite cannot resolve .onion addresses through clearnet DNS.
pub fn relay_fetch_bootstrap(
pubkey_hex: &str,
relay_urls: &[String],
) -> Result<i32, Box<dyn std::error::Error>> {
if pubkey_hex.is_empty() {
eprintln!("[relay] No pubkey, skipping bootstrap fetch");
return Ok(0);
}
if relay_urls.is_empty() {
eprintln!("[relay] No bootstrap relays configured");
return Ok(0);
}
// Filter out .onion relays when Tor is not available.
// Tor may be running as a system daemon (not managed by the browser),
// so check the SOCKS port availability rather than just internal status.
let tor_available = crate::net_services::net_services_get_tor_socks_endpoint().is_some();
let filtered: Vec<String> = relay_urls.iter()
.filter(|url| {
if url.contains(".onion") && !tor_available {
println!("[relay] Skipping .onion relay (Tor not available): {}", url);
false
} else {
true
}
})
.cloned()
.collect();
if filtered.is_empty() {
eprintln!("[relay] All relays are .onion and Tor is not running");
return Ok(0);
}
println!(
"[relay] Fetching kind 0/3/10002/30003/30078/31123 for {} from {} relay(s)...",
pubkey_hex,
filtered.len()
);
// Build the filter
let filter = Filter::new()
.authors(vec![pubkey_hex.to_string()])
.kinds(vec![0, 3, 10002, 30003, 30078, 31123]);
// Use tokio runtime for async relay operations
let rt = Runtime::new()?;
let result = rt.block_on(async {
use nostr_relay::pool::RelayPool;
let pool = RelayPool::new(Some(ReconnectConfig::default()));
// Add relays
for url in filtered.iter().take(MAX_RELAYS) {
let _ = pool.add_relay(url.as_str()).await;
}
// Connect to relays
pool.connect_all().await;
// Query with timeout
let events_result = tokio::time::timeout(
std::time::Duration::from_millis(RELAY_TIMEOUT_MS),
pool.query_sync(&filtered, filter, RELAY_TIMEOUT_MS),
)
.await;
match events_result {
Ok(Ok(event_list)) => {
let count = event_list.len();
println!("[relay] Fetched {} events", count);
// Store events in database
for event in &event_list {
let tags_json = serde_json::to_string(&event.tags).unwrap_or_default();
let _ = db::db_store_event(
&event.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(),
&event.pubkey.to_hex(),
event.kind.as_u64(),
event.created_at,
&event.content,
&tags_json,
event.sig.as_ref().map(|s| s.to_hex()).as_deref(),
None,
);
}
// Disconnect
pool.disconnect_all().await;
Ok(count as i32)
}
_ => {
eprintln!("[relay] Query timed out or failed");
pool.disconnect_all().await;
Ok(0)
}
}
});
result
}
/// Background thread entry point for relay_fetch_bootstrap.
pub fn relay_fetch_thread(pubkey_hex: String) {
let relays = settings::settings_get_bootstrap_relays();
let relay_refs: Vec<String> = relays.iter().map(|s| s.clone()).collect();
match relay_fetch_bootstrap(&pubkey_hex, &relay_refs) {
Ok(count) => println!("[relay] Bootstrap fetch complete: {} events stored", count),
Err(e) => eprintln!("[relay] Bootstrap fetch error: {}", e),
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Search engine integration
//!
//! Port of `search.c` / `search.h` from the C project.
use crate::settings;
/// Perform a web search using the configured search engine.
pub fn search_query(query: &str) -> String {
let settings = settings::settings_get();
let encoded_query = urlencoding::encode(query);
match settings.search_engine.as_str() {
"duckduckgo" => format!("https://duckduckgo.com/?q={}", encoded_query),
"google" => format!("https://www.google.com/search?q={}", encoded_query),
"bing" => format!("https://www.bing.com/search?q={}", encoded_query),
"brave" => format!("https://search.brave.com/search?q={}", encoded_query),
_ => format!("https://duckduckgo.com/?q={}", encoded_query),
}
}
/// Check if a string looks like a URL (vs a search query).
pub fn search_is_url(input: &str) -> bool {
input.starts_with("http://")
|| input.starts_with("https://")
|| input.starts_with("file://")
|| input.starts_with("about:")
|| input.starts_with("sovereign://")
|| input.starts_with("nostr://")
|| input.starts_with("fips://")
|| input.starts_with("tor://")
|| input.contains('.')
}
/// Normalize a URL string: prepend `https://` if it has no scheme.
/// Returns the normalized URL.
pub fn normalize_url(input: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() {
return trimmed.to_string();
}
// If it already has a scheme, return as-is.
if trimmed.contains("://") || trimmed.starts_with("about:") {
return trimmed.to_string();
}
// Otherwise treat as a bare domain and prepend https://
format!("https://{}", trimmed)
}
+43
View File
@@ -0,0 +1,43 @@
//! Session save/restore for sovereign_browser
//!
//! Port of `session.c` / `session.h` from the C project.
use crate::db;
use crate::tab_manager;
/// Save the current session (open tabs) to the database.
pub fn session_save() {
let tabs = tab_manager::tab_manager_get_tabs();
let tab_data: Vec<serde_json::Value> = tabs
.iter()
.map(|t| {
serde_json::json!({
"id": t.id,
"title": t.title,
"url": t.url,
})
})
.collect();
if let Ok(json) = serde_json::to_string(&tab_data) {
let _ = db::db_kv_set("session.tabs", &json);
}
}
/// Restore the previous session.
pub fn session_restore() -> Vec<String> {
if let Ok(Some(json)) = db::db_kv_get("session.tabs") {
if let Ok(tabs) = serde_json::from_str::<Vec<serde_json::Value>>(&json) {
return tabs
.iter()
.filter_map(|t| t["url"].as_str().map(|s| s.to_string()))
.collect();
}
}
Vec::new()
}
/// Clear the saved session.
pub fn session_clear() {
let _ = db::db_kv_delete("session.tabs");
}
+171
View File
@@ -0,0 +1,171 @@
//! Browser settings management
//!
//! Port of `settings.c` / `settings.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::db;
/// Browser settings structure.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct BrowserSettings {
pub new_tab_url: String,
pub tab_bar_position: i32,
pub show_tab_close_buttons: bool,
pub middle_click_close: bool,
pub ctrl_tab_switch: bool,
pub tab_drag_reorder: bool,
pub max_tabs: i32,
pub bootstrap_relays: String,
pub search_engine: String,
pub agent_port: i32,
pub agent_allowed_origins: String,
pub session_restore: bool,
pub theme_dark: bool,
pub enable_webgl: bool,
pub enable_webrtc: bool,
pub enable_javascript: bool,
pub enable_plugins: bool,
pub allow_modal_dialogs: bool,
pub allow_popups: bool,
pub enable_developer_extras: bool,
pub enable_private_browsing: bool,
pub enable_fullscreen: bool,
pub enable_media_stream: bool,
pub enable_media_source: bool,
pub enable_smooth_scrolling: bool,
pub enable_accelerated_2d_canvas: bool,
pub enable_write_console_messages_to_stdout: bool,
}
impl Default for BrowserSettings {
fn default() -> Self {
BrowserSettings {
new_tab_url: "about:blank".to_string(),
tab_bar_position: 0,
show_tab_close_buttons: true,
middle_click_close: true,
ctrl_tab_switch: true,
tab_drag_reorder: true,
max_tabs: 50,
bootstrap_relays: "wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol,wss://relay.nostr.band".to_string(),
search_engine: "duckduckgo".to_string(),
agent_port: 17777,
agent_allowed_origins: "http://localhost:*".to_string(),
session_restore: true,
theme_dark: false,
enable_webgl: true,
enable_webrtc: true,
enable_javascript: true,
enable_plugins: false,
allow_modal_dialogs: true,
allow_popups: false,
enable_developer_extras: false,
enable_private_browsing: false,
enable_fullscreen: true,
enable_media_stream: true,
enable_media_source: true,
enable_smooth_scrolling: true,
enable_accelerated_2d_canvas: true,
enable_write_console_messages_to_stdout: false,
}
}
}
/// Global settings singleton.
static G_SETTINGS: Lazy<Mutex<BrowserSettings>> = Lazy::new(|| Mutex::new(BrowserSettings::default()));
/// Get a reference to the current settings.
pub fn settings_get() -> BrowserSettings {
G_SETTINGS.lock().unwrap().clone()
}
/// Update settings from a JSON value.
pub fn settings_update(json: &serde_json::Value) {
let mut settings = G_SETTINGS.lock().unwrap();
if let Some(v) = json.get("new_tab_url").and_then(|v| v.as_str()) {
settings.new_tab_url = v.to_string();
}
if let Some(v) = json.get("tab_bar_position").and_then(|v| v.as_i64()) {
settings.tab_bar_position = v as i32;
}
if let Some(v) = json.get("show_tab_close_buttons").and_then(|v| v.as_bool()) {
settings.show_tab_close_buttons = v;
}
if let Some(v) = json.get("middle_click_close").and_then(|v| v.as_bool()) {
settings.middle_click_close = v;
}
if let Some(v) = json.get("ctrl_tab_switch").and_then(|v| v.as_bool()) {
settings.ctrl_tab_switch = v;
}
if let Some(v) = json.get("tab_drag_reorder").and_then(|v| v.as_bool()) {
settings.tab_drag_reorder = v;
}
if let Some(v) = json.get("max_tabs").and_then(|v| v.as_i64()) {
settings.max_tabs = v as i32;
}
if let Some(v) = json.get("bootstrap_relays").and_then(|v| v.as_str()) {
settings.bootstrap_relays = v.to_string();
}
if let Some(v) = json.get("search_engine").and_then(|v| v.as_str()) {
settings.search_engine = v.to_string();
}
if let Some(v) = json.get("agent_port").and_then(|v| v.as_i64()) {
settings.agent_port = v as i32;
}
if let Some(v) = json.get("session_restore").and_then(|v| v.as_bool()) {
settings.session_restore = v;
}
if let Some(v) = json.get("theme_dark").and_then(|v| v.as_bool()) {
settings.theme_dark = v;
}
}
/// Load settings from the database.
pub fn settings_load() {
if let Ok(Some(json_str)) = db::db_kv_get("browser_settings") {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
settings_update(&json);
}
}
}
/// Save settings to the database.
pub fn settings_save() {
let settings = G_SETTINGS.lock().unwrap();
let json = serde_json::json!({
"new_tab_url": settings.new_tab_url,
"tab_bar_position": settings.tab_bar_position,
"show_tab_close_buttons": settings.show_tab_close_buttons,
"middle_click_close": settings.middle_click_close,
"ctrl_tab_switch": settings.ctrl_tab_switch,
"tab_drag_reorder": settings.tab_drag_reorder,
"max_tabs": settings.max_tabs,
"bootstrap_relays": settings.bootstrap_relays,
"search_engine": settings.search_engine,
"agent_port": settings.agent_port,
"session_restore": settings.session_restore,
"theme_dark": settings.theme_dark,
});
if let Ok(json_str) = serde_json::to_string(&json) {
let _ = db::db_kv_set("browser_settings", &json_str);
}
// Trigger NIP-78 settings sync (debounced in the C version; immediate here).
// The sync module handles the case where no signer is available.
crate::settings_sync::settings_sync_publish();
}
/// Get bootstrap relays as a vector.
pub fn settings_get_bootstrap_relays() -> Vec<String> {
let settings = G_SETTINGS.lock().unwrap();
settings.bootstrap_relays
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
+102
View File
@@ -0,0 +1,102 @@
//! NIP-78 (kind 30078) settings sync for sovereign_browser
//!
//! Port of `settings_sync.c` / `settings_sync.h` from the C project.
//! Uses `nostr_signer` and `nostr_relay` from `rust_core_lib`.
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::db;
use crate::settings;
/// The d-tag identifier used for the shared kind 30078 user-settings event.
const SETTINGS_SYNC_D_TAG: &str = "user-settings";
/// The Nostr kind for arbitrary custom app data (NIP-78).
const _SETTINGS_SYNC_KIND: u64 = 30078;
/// Global signer state for settings sync.
static G_SYNC_STATE: Lazy<Mutex<SyncState>> = Lazy::new(|| Mutex::new(SyncState::default()));
struct SyncState {
signer: Option<Arc<dyn NostrSigner>>,
pubkey_hex: String,
have_signer: bool,
}
impl Default for SyncState {
fn default() -> Self {
SyncState {
signer: None,
pubkey_hex: String::new(),
have_signer: false,
}
}
}
/// Initialize the settings sync module.
pub fn settings_sync_init(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
let mut state = G_SYNC_STATE.lock().unwrap();
state.signer = signer;
state.pubkey_hex = pubkey_hex.to_string();
state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty();
}
/// Update the signer reference.
pub fn settings_sync_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
settings_sync_init(signer, pubkey_hex);
}
/// Serialize syncable settings, build and sign a kind 30078 event.
pub fn settings_sync_publish() {
let state = G_SYNC_STATE.lock().unwrap();
if !state.have_signer {
return;
}
let browser_settings = settings::settings_get();
let payload = serde_json::json!({
"v": 2,
"updatedAt": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
"sovereign_browser": {
"new_tab_url": browser_settings.new_tab_url,
"tab_bar_position": browser_settings.tab_bar_position,
"show_tab_close_buttons": browser_settings.show_tab_close_buttons,
"middle_click_close": browser_settings.middle_click_close,
"ctrl_tab_switch": browser_settings.ctrl_tab_switch,
"tab_drag_reorder": browser_settings.tab_drag_reorder,
"max_tabs": browser_settings.max_tabs,
"bootstrap_relays": browser_settings.bootstrap_relays,
"search_engine": browser_settings.search_engine,
}
});
if let Ok(payload_str) = serde_json::to_string(&payload) {
let _ = db::db_kv_set("settings_sync.last_payload", &payload_str);
}
println!("[settings_sync] Publish requested (NIP-44 encryption not yet implemented)");
}
/// Merge settings from a fetched kind 30078 event.
pub fn settings_sync_merge_from_nostr(event_json: &serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
let content = event_json["content"]
.as_str()
.ok_or("Missing content")?;
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(browser) = payload.get("sovereign_browser") {
settings::settings_update(browser);
settings::settings_save();
println!("[settings_sync] Merged settings from Nostr event");
}
}
Ok(())
}
+99
View File
@@ -0,0 +1,99 @@
//! Keyboard shortcuts for sovereign_browser
//!
//! Port of `shortcuts.c` / `shortcuts.h` from the C project.
use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// A keyboard shortcut binding.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Shortcut {
pub key: String,
pub modifiers: String, // "Ctrl", "Ctrl+Shift", etc.
pub action: String,
}
/// Global shortcuts registry.
static G_SHORTCUTS: Lazy<Mutex<HashMap<String, Shortcut>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Initialize default shortcuts.
/// Mirrors the C version (shortcuts.c:24) with all 20+ shortcuts.
pub fn shortcuts_init() {
let mut shortcuts = G_SHORTCUTS.lock().unwrap();
shortcuts.clear();
let defaults = vec![
Shortcut { key: "t".to_string(), modifiers: "Ctrl".to_string(), action: "new_tab".to_string() },
Shortcut { key: "n".to_string(), modifiers: "Ctrl".to_string(), action: "new_window".to_string() },
Shortcut { key: "o".to_string(), modifiers: "Ctrl".to_string(), action: "open_file".to_string() },
Shortcut { key: "w".to_string(), modifiers: "Ctrl".to_string(), action: "close_tab".to_string() },
Shortcut { key: "l".to_string(), modifiers: "Ctrl".to_string(), action: "focus_url".to_string() },
Shortcut { key: "Tab".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab".to_string() },
Shortcut { key: "Tab".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "prev_tab".to_string() },
Shortcut { key: "Page_Down".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab_pagedown".to_string() },
Shortcut { key: "Page_Up".to_string(), modifiers: "Ctrl".to_string(), action: "prev_tab_pageup".to_string() },
Shortcut { key: "r".to_string(), modifiers: "Ctrl".to_string(), action: "reload".to_string() },
Shortcut { key: "F5".to_string(), modifiers: "".to_string(), action: "reload".to_string() },
Shortcut { key: "F5".to_string(), modifiers: "Shift".to_string(), action: "force_reload".to_string() },
Shortcut { key: "Left".to_string(), modifiers: "Alt".to_string(), action: "go_back".to_string() },
Shortcut { key: "Right".to_string(), modifiers: "Alt".to_string(), action: "go_forward".to_string() },
Shortcut { key: "f".to_string(), modifiers: "Ctrl".to_string(), action: "find".to_string() },
Shortcut { key: "comma".to_string(), modifiers: "Ctrl".to_string(), action: "open_settings".to_string() },
Shortcut { key: "Escape".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "open_processes".to_string() },
Shortcut { key: "u".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "new_identity".to_string() },
Shortcut { key: "F11".to_string(), modifiers: "".to_string(), action: "toggle_fullscreen".to_string() },
Shortcut { key: "i".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_inspector".to_string() },
Shortcut { key: "a".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_sidebar".to_string() },
Shortcut { key: "m".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_toolbars".to_string() },
Shortcut { key: "plus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_in".to_string() },
Shortcut { key: "minus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_out".to_string() },
Shortcut { key: "0".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_reset".to_string() },
Shortcut { key: "d".to_string(), modifiers: "Ctrl".to_string(), action: "bookmark_page".to_string() },
Shortcut { key: "h".to_string(), modifiers: "Ctrl".to_string(), action: "show_history".to_string() },
Shortcut { key: "b".to_string(), modifiers: "Ctrl".to_string(), action: "show_bookmarks".to_string() },
Shortcut { key: "j".to_string(), modifiers: "Ctrl".to_string(), action: "show_downloads".to_string() },
Shortcut { key: "q".to_string(), modifiers: "Ctrl".to_string(), action: "quit".to_string() },
];
for s in defaults {
let key = format!("{}+{}", s.modifiers, s.key);
shortcuts.insert(key, s);
}
}
/// Get a shortcut by key combination.
pub fn shortcuts_get(modifiers: &str, key: &str) -> Option<Shortcut> {
let shortcuts = G_SHORTCUTS.lock().unwrap();
let key_combo = format!("{}+{}", modifiers, key);
shortcuts.get(&key_combo).cloned()
}
/// Get all shortcuts.
pub fn shortcuts_get_all() -> Vec<Shortcut> {
let shortcuts = G_SHORTCUTS.lock().unwrap();
shortcuts.values().cloned().collect()
}
/// Load shortcuts from the database.
pub fn shortcuts_load() {
if let Ok(Some(json)) = crate::db::db_kv_get("shortcuts") {
if let Ok(loaded) = serde_json::from_str::<Vec<Shortcut>>(&json) {
let mut shortcuts = G_SHORTCUTS.lock().unwrap();
shortcuts.clear();
for s in loaded {
let key = format!("{}+{}", s.modifiers, s.key);
shortcuts.insert(key, s);
}
}
}
}
/// Save shortcuts to the database.
pub fn shortcuts_save() {
let shortcuts = G_SHORTCUTS.lock().unwrap();
let list: Vec<Shortcut> = shortcuts.values().cloned().collect();
if let Ok(json) = serde_json::to_string(&list) {
let _ = crate::db::db_kv_set("shortcuts", &json);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Website downloader for offline browsing
//!
//! Port of `site_downloader.c` / `site_downloader.h` from the C project.
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Download status.
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum DownloadStatus {
Idle,
Downloading,
Complete,
Error(String),
}
/// Site downloader state.
static G_DOWNLOADER: Lazy<Mutex<DownloaderState>> = Lazy::new(|| Mutex::new(DownloaderState::default()));
struct DownloaderState {
status: DownloadStatus,
current_url: String,
}
impl Default for DownloaderState {
fn default() -> Self {
DownloaderState {
status: DownloadStatus::Idle,
current_url: String::new(),
}
}
}
/// Initialize the site downloader.
pub fn site_downloader_init() {
let mut state = G_DOWNLOADER.lock().unwrap();
state.status = DownloadStatus::Idle;
}
/// Download a website for offline browsing.
pub fn site_downloader_download(url: &str, _output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_DOWNLOADER.lock().unwrap();
state.status = DownloadStatus::Downloading;
state.current_url = url.to_string();
// TODO: implement actual site download
println!("[downloader] Downloading {} (not yet implemented)", url);
state.status = DownloadStatus::Complete;
Ok(())
}
/// Get the current download status.
pub fn site_downloader_get_status() -> DownloadStatus {
let state = G_DOWNLOADER.lock().unwrap();
state.status.clone()
}
+1552
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
//! Tor control port interface
//!
//! Port of `tor_control.c` / `tor_control.h` from the C project.
//! Implements a synchronous, short-timeout Tor control-protocol client
//! supporting both TCP (host:port) and Unix socket endpoints.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::Mutex;
use once_cell::sync::Lazy;
const TOR_RESPONSE_MAX: usize = 8192;
const TOR_TIMEOUT_MS: u64 = 1500;
/// Tor control state.
static G_TOR_CONTROL: Lazy<Mutex<TorControlState>> = Lazy::new(|| Mutex::new(TorControlState::default()));
struct TorControlState {
connected: bool,
control_host: String,
control_port: u16,
password: String,
cookie_path: String,
}
impl Default for TorControlState {
fn default() -> Self {
TorControlState {
connected: false,
control_host: "127.0.0.1".to_string(),
control_port: 9051,
password: String::new(),
cookie_path: String::new(),
}
}
}
/// Initialize Tor control.
pub fn tor_control_init() {
let mut state = G_TOR_CONTROL.lock().unwrap();
state.connected = false;
}
/// Connect to the Tor control port (TCP host:port).
pub fn tor_control_connect() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_TOR_CONTROL.lock().unwrap();
let host = state.control_host.clone();
let port = state.control_port;
let cookie = state.cookie_path.clone();
let password = state.password.clone();
// Connect to the control port.
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr)?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
// Authenticate.
let auth_cmd = if !cookie.is_empty() {
// Read the cookie file and hex-encode it.
let cookie_bytes = std::fs::read(&cookie)?;
let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect();
format!("AUTHENTICATE {}\r\n", hex)
} else if !password.is_empty() {
format!("AUTHENTICATE \"{}\"\r\n", password)
} else {
"AUTHENTICATE\r\n".to_string()
};
stream.write_all(auth_cmd.as_bytes())?;
let response = read_response(&mut stream)?;
if !response.starts_with("250") {
return Err(format!("Tor authentication failed: {}", response).into());
}
state.connected = true;
println!("[tor] Connected to control port {}:{}", host, port);
Ok(())
}
/// Disconnect from Tor control port.
pub fn tor_control_disconnect() {
let mut state = G_TOR_CONTROL.lock().unwrap();
state.connected = false;
println!("[tor] Disconnected from control port");
}
/// Check if connected to Tor control.
pub fn tor_control_is_connected() -> bool {
let state = G_TOR_CONTROL.lock().unwrap();
state.connected
}
/// Send a command to the Tor control port and return the response.
pub fn tor_control_send_command(command: &str) -> Result<String, Box<dyn std::error::Error>> {
let state = G_TOR_CONTROL.lock().unwrap();
if !state.connected {
return Err("Not connected to Tor control port".into());
}
let host = state.control_host.clone();
let port = state.control_port;
// Open a fresh connection for the command (synchronous, short-lived).
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr)?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
// Authenticate first (needed for most commands).
let auth_cmd = if !state.cookie_path.is_empty() {
let cookie_bytes = std::fs::read(&state.cookie_path)?;
let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect();
format!("AUTHENTICATE {}\r\n", hex)
} else if !state.password.is_empty() {
format!("AUTHENTICATE \"{}\"\r\n", state.password)
} else {
"AUTHENTICATE\r\n".to_string()
};
stream.write_all(auth_cmd.as_bytes())?;
let _ = read_response(&mut stream)?;
// Send the actual command.
let wire = format!("{}\r\n", command);
stream.write_all(wire.as_bytes())?;
let response = read_response(&mut stream)?;
Ok(response)
}
/// Request a new Tor identity (SIGNAL NEWNYM).
pub fn tor_control_new_identity() -> Result<String, Box<dyn std::error::Error>> {
tor_control_send_command("SIGNAL NEWNYM")
}
/// Get the Tor bootstrap progress.
pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box<dyn std::error::Error>> {
let response = tor_control_send_command("GETINFO status/bootstrap-phase")?;
// Parse "PROGRESS=NN" from the response.
let progress = response
.find("PROGRESS=")
.and_then(|i| response[i + 9..].split(|c: char| !c.is_ascii_digit()).next())
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
Ok((progress, response))
}
/// Read a full Tor control response (until a line with "250 " or an error code).
fn read_response(stream: &mut dyn Read) -> Result<String, Box<dyn std::error::Error>> {
let mut buf = [0u8; TOR_RESPONSE_MAX];
let mut used = 0usize;
while used < TOR_RESPONSE_MAX {
let n = stream.read(&mut buf[used..])?;
if n == 0 {
break;
}
used += n;
let text = String::from_utf8_lossy(&buf[..used]);
// Check for a final line: "250 " (success) or "5xx " (error).
for line in text.lines() {
if line.len() >= 4 && line.as_bytes()[3] == b' ' {
let code = &line[..3];
if code == "250" {
return Ok(text.to_string());
}
if code.starts_with('5') || code.starts_with('4') {
return Err(format!("Tor control error: {}", line).into());
}
}
}
}
Ok(String::from_utf8_lossy(&buf[..used]).to_string())
}
+226
View File
@@ -0,0 +1,226 @@
//! WebKitGTK tor:// URI scheme handler
//!
//! Port of `tor_scheme.c` / `tor_scheme.h` from the C project.
//! Fetches .onion content through Tor's SOCKS5 proxy using reqwest.
//!
//! Architecture (mirrors the C version's GTask thread pattern):
//! 1. Main thread: parse URI, spawn blocking HTTP worker thread
//! 2. Worker thread: fetch via reqwest through Tor SOCKS proxy
//! 3. Main thread: poll for result via glib timeout, finish request
use std::sync::{Arc, Mutex};
use glib::Bytes;
use gio::MemoryInputStream;
use webkit2gtk::*;
/// Default Tor SOCKS endpoint (socks5h = remote DNS resolution through Tor).
const DEFAULT_SOCKS_ENDPOINT: &str = "socks5h://127.0.0.1:9050";
/// Maximum response size (16 MB, matching the C version).
const TOR_FETCH_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// Poll interval for checking worker thread completion (milliseconds).
const POLL_MS: u32 = 50;
/// Register the tor:// URI scheme.
pub fn tor_scheme_register(ctx: &WebContext) {
ctx.register_uri_scheme("tor", |request| {
handle_tor_scheme(request);
});
}
/// Handle a tor:// URI scheme request.
///
/// Parses the tor:// URI, validates the host is a .onion address, then
/// fetches the content asynchronously through Tor's SOCKS5 proxy.
/// Uses a worker thread + main-thread polling pattern because
/// URISchemeRequest is not Send in webkit2gtk 2.0.2.
fn handle_tor_scheme(request: &URISchemeRequest) {
let uri = request.uri().unwrap_or_default();
// Parse the URI and build the target HTTP URL.
let target_url = match build_target_url(&uri) {
Some(url) => url,
None => {
respond_error(request, "Invalid .onion URL",
&format!("Cannot parse tor:// URI: {}", uri));
return;
}
};
// Get the Tor SOCKS endpoint from net_services, falling back to default.
let socks_endpoint = crate::net_services::net_services_get_tor_socks_endpoint()
.unwrap_or_else(|| DEFAULT_SOCKS_ENDPOINT.to_string());
// Shared result buffer: worker thread writes, main thread reads.
let result: Arc<Mutex<Option<Result<(Vec<u8>, Option<String>), String>>>> = Arc::new(Mutex::new(None));
let result_worker = result.clone();
// Spawn a blocking worker thread (reqwest doesn't need async here).
std::thread::spawn(move || {
let r = fetch_via_socks_blocking(&target_url, &socks_endpoint);
*result_worker.lock().unwrap() = Some(r);
});
// Clone the request (GObject refcounted) for the main-thread callback.
let request_clone = request.clone();
// Poll for completion on the main thread via glib timeout.
// `timeout_add_local` does NOT require Send, so URISchemeRequest is fine.
glib::timeout_add_local(std::time::Duration::from_millis(POLL_MS as u64), move || {
let r = result.lock().unwrap().take();
match r {
Some(Ok((body, content_type))) => {
let ct = content_type.unwrap_or_else(|| "text/html".to_string());
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request_clone.finish(&stream, body.len() as i64, Some(&ct));
glib::ControlFlow::Break
}
Some(Err(e)) => {
respond_error(&request_clone, "Tor request failed", &e);
glib::ControlFlow::Break
}
None => {
// Not ready yet — keep polling.
glib::ControlFlow::Continue
}
}
});
}
/// Build an HTTP target URL from a tor:// URI.
///
/// Extracts the host (must end in .onion), optional port, and path/query,
/// then builds an `http://` URL. Phase 1 maps both http:// and https://
/// .onion URLs to http:// over Tor (matching the C version).
fn build_target_url(uri: &str) -> Option<String> {
let stripped = uri.strip_prefix("tor://")?;
// Split authority from path/query.
let (authority, rest) = if let Some(idx) = stripped.find('/') {
let (auth, path) = stripped.split_at(idx);
(auth, path)
} else {
(stripped, "/")
};
// Extract host (strip userinfo@ if present, e.g. user:pass@host).
let host = if let Some(at_idx) = authority.rfind('@') {
&authority[at_idx + 1..]
} else {
authority
};
// Validate .onion suffix.
if !host.ends_with(".onion") {
return None;
}
// Extract port if present (handle IPv6 brackets).
let (hostname, port_str) = if host.starts_with('[') {
// IPv6 address — find closing bracket.
if let Some(bracket_end) = host.find(']') {
let h = &host[..=bracket_end];
let rest_after = &host[bracket_end + 1..];
if let Some(port) = rest_after.strip_prefix(':') {
(h, Some(port.to_string()))
} else {
(h, None)
}
} else {
(host, None)
}
} else if let Some(colon_idx) = host.rfind(':') {
let h = &host[..colon_idx];
let p = &host[colon_idx + 1..];
(h, Some(p.to_string()))
} else {
(host, None)
};
// Build the HTTP URL.
let target = match port_str {
Some(ref port) => format!("http://{}:{}{}", hostname, port, rest),
None => format!("http://{}{}", hostname, rest),
};
Some(target)
}
/// Fetch a URL through a SOCKS5 proxy using blocking reqwest.
///
/// Uses `socks5h://` (remote DNS) so the .onion hostname is resolved
/// through Tor, not via clearnet DNS.
fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec<u8>, Option<String>), String> {
use reqwest::blocking::Client;
use reqwest::header::CONTENT_TYPE;
let client: Client = match Client::builder()
.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?)
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(15))
.user_agent("sovereign_browser/tor-scheme")
.build()
{
Ok(c) => c,
Err(e) => return Err(e.to_string()),
};
let response: reqwest::blocking::Response = match client.get(url).send() {
Ok(r) => r,
Err(e) => return Err(e.to_string()),
};
// Check content-length header before reading the body.
if let Some(content_length) = response.content_length() {
if content_length > TOR_FETCH_MAX_BYTES {
return Err(format!("Response too large: {} bytes (max {})", content_length, TOR_FETCH_MAX_BYTES));
}
}
let content_type: Option<String> = response.headers()
.get(CONTENT_TYPE)
.and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok())
.map(|s: &str| s.to_string());
let bytes: Vec<u8> = match response.bytes() {
Ok(b) => b.to_vec(),
Err(e) => return Err(e.to_string()),
};
if bytes.len() as u64 > TOR_FETCH_MAX_BYTES {
return Err(format!("Response too large: {} bytes (max {})", bytes.len(), TOR_FETCH_MAX_BYTES));
}
Ok((bytes, content_type))
}
/// Respond with an error HTML page.
fn respond_error(request: &URISchemeRequest, title: &str, message: &str) {
let html = format!(
"<!doctype html><html><head><meta charset=\"utf-8\">\
<title>{}</title></head><body>\
<h1>{}</h1><p>{}</p></body></html>",
html_escape(title),
html_escape(title),
html_escape(message)
);
let body = html.into_bytes();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
request.finish(&stream, body.len() as i64, Some("text/html"));
}
/// HTML-escape a string for safe embedding in HTML.
fn html_escape(s: &str) -> String {
let amp = format!("{}amp;", '&');
let lt = format!("{}lt;", '&');
let gt = format!("{}gt;", '&');
let quot = format!("{}quot;", '&');
let apos = format!("{}#39;", '&');
s.replace('&', &amp)
.replace('<', &lt)
.replace('>', &gt)
.replace('"', &quot)
.replace("'", &apos)
}
+13
View File
@@ -0,0 +1,13 @@
//! Version information for sovereign_browser
/// The current version of sovereign_browser (with leading 'v').
pub const VERSION: &str = "v0.0.2";
/// Major version number.
pub const VERSION_MAJOR: u32 = 0;
/// Minor version number.
pub const VERSION_MINOR: u32 = 0;
/// Patch version number.
pub const VERSION_PATCH: u32 = 2;
+57
View File
@@ -0,0 +1,57 @@
//! WebKitWebContext management
//!
//! Port of `web_context.c` / `web_context.h` from the C project.
use webkit2gtk::*;
/// Create and configure a WebKitWebContext.
pub fn web_context_create() -> WebContext {
let ctx = WebContext::new();
// Register custom URI schemes
crate::nostr_scheme::nostr_scheme_register(&ctx);
crate::tor_scheme::tor_scheme_register(&ctx);
crate::local_scheme::local_scheme_register(&ctx);
// Security strip: register sovereign://, tor://, file://, ws:// as secure
// (no CORS enforcement / mixed-content blocking). Without this, WebKit
// blocks sovereign://nostr/* requests from HTTPS pages as "insecure
// content". Mirrors the C version (web_context.c:209).
if let Some(sec_mgr) = ctx.security_manager() {
sec_mgr.register_uri_scheme_as_secure("sovereign");
sec_mgr.register_uri_scheme_as_secure("tor");
sec_mgr.register_uri_scheme_as_secure("file");
sec_mgr.register_uri_scheme_as_secure("ws");
sec_mgr.register_uri_scheme_as_local("file");
// Register nostr:// as secure too so NIP-19 URIs work from HTTPS pages.
sec_mgr.register_uri_scheme_as_secure("nostr");
}
ctx
}
/// Configure WebKitSettings for a web view.
pub fn web_context_configure_settings(settings: &webkit2gtk::Settings) {
settings.set_enable_javascript(true);
settings.set_enable_webgl(true);
settings.set_enable_webrtc(true);
// set_enable_plugins is deprecated since WebKitGTK 2.32 but there is no
// replacement; plugins are effectively dead in modern WebKit. Keep the
// call to explicitly disable them.
#[allow(deprecated)]
settings.set_enable_plugins(false);
settings.set_allow_modal_dialogs(true);
settings.set_enable_developer_extras(false);
// set_enable_private_browsing is deprecated in WebKitGTK 2.40+;
// use WebContext::set_is_ephemeral() instead. We don't need it here.
settings.set_enable_fullscreen(true);
settings.set_enable_media_stream(true);
settings.set_enable_smooth_scrolling(true);
// Deprecated since 2.32 with no replacement; keep for explicitness.
#[allow(deprecated)]
settings.set_enable_accelerated_2d_canvas(true);
// Enable console messages to stdout so we can debug window.nostr injection
// and page-level issues. The C version connects to console-message signal;
// this is simpler for now.
settings.set_enable_write_console_messages_to_stdout(true);
}
+27
View File
@@ -0,0 +1,27 @@
//! WebKit data store management
//!
//! Port of `webkit_data.c` / `webkit_data.h` from the C project.
use webkit2gtk::*;
use std::path::PathBuf;
/// Get the data directory for WebKit storage.
pub fn webkit_data_get_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser").join("webkit_data")
}
/// Configure the website data manager for a profile.
pub fn webkit_data_configure(_web_context: &WebContext, pubkey_hex: &str) {
let data_dir = webkit_data_get_dir().join(pubkey_hex);
std::fs::create_dir_all(&data_dir).ok();
// Use WebsiteDataManager builder pattern.
// local_storage_directory is deprecated since WebKitGTK 2.40 in favour of
// passing the base directory, but the builder API is the only way to set
// per-profile storage in webkit2gtk 2.0.2 Rust bindings.
#[allow(deprecated)]
let _manager = WebsiteDataManager::builder()
.local_storage_directory(data_dir.join("localstorage").to_str().unwrap_or(""))
.build();
}
+452
View File
@@ -0,0 +1,452 @@
/* Shared sovereign:// page CSS (theme variables, base elements, buttons).
* Mirrors sovereign_page_css() in src/nostr_bridge.c so the chat page
* stays consistent with settings/profile/bookmarks pages. */
:root {
--font: monospace;
--primary: #000000;
--secondary: #ffffff;
--accent: #ff0000;
--muted: #dddddd;
--border: var(--muted);
--radius: 5px;
--bg: var(--secondary);
--fg: var(--primary);
--img-gray: 100%;
--img-gray-hover: 20%;
color-scheme: light;
}
html.dark {
--primary: #ffffff;
--secondary: #000000;
--accent: #ff0000;
--muted: #777777;
--border: var(--muted);
color-scheme: dark;
}
* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }
html { transition: background-color 0.2s ease, color 0.2s ease; }
body { color: var(--fg); background: var(--bg); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; transition: 0.2s; }
a:hover { text-decoration: underline; }
input, select, textarea {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); font-size: 13px;
min-width: 200px;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn {
background: var(--bg); color: var(--primary);
border: 1px solid var(--primary); border-radius: var(--radius);
padding: 6px 16px; cursor: pointer; font-family: var(--font);
font-size: 13px; font-weight: bold; margin-left: 8px;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.btn:hover, .save-btn:hover { border-color: var(--accent); }
.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-del { border-color: var(--accent); color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
/* ── Chat page layout (tabbed) ───────────────────────────────── */
#chat-root {
display: flex; flex-direction: column;
height: 100vh; padding: 8px;
}
/* Tab bar — horizontal row of tab buttons. */
#tab-bar {
display: flex; flex-direction: row; gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0; margin-bottom: 8px;
}
.tab {
background: transparent; color: var(--fg);
border: none; border-bottom: 2px solid transparent;
padding: 8px 14px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; transition: border-color 0.15s, color 0.15s;
}
.tab:hover { color: var(--accent); }
.tab.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
/* Tab content — only the active tab is visible. */
.tab-content {
display: none; flex: 1; min-height: 0;
flex-direction: column;
}
.tab-content.active { display: flex; }
/* Toolbar inside a tab (refresh + action buttons). */
.tab-toolbar {
display: flex; flex-direction: row; gap: 6px;
flex-shrink: 0; margin-bottom: 8px;
}
.tab-toolbar .btn { margin-left: 0; }
#new-chat-btn { margin-left: 0; }
#conv-list {
flex: 1; overflow-y: auto; display: flex;
flex-direction: column; gap: 4px;
}
/* Conversation list items — match ai.html conversation styling:
* title + timestamp on the left, delete button appears on hover. */
.conv-item {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px; border-radius: var(--radius); cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover {
background: rgba(128,128,128,0.08);
}
.conv-active {
border-color: var(--accent);
background: rgba(255,0,0,0.06);
}
.conv-item-content {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
}
.conv-title {
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-size: 0.9em; color: var(--fg);
}
.conv-timestamp {
font-size: 0.72em; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.conv-del {
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
opacity: 0; transition: opacity 0.15s;
}
.conv-item:hover .conv-del { opacity: 1; }
.conv-empty { color: var(--muted); font-size: 0.85em; padding: 8px; }
#create-skill-btn { margin-left: 0; }
/* Rename button — same hover-reveal behavior as the delete button. */
.conv-rename {
flex-shrink: 0; padding: 2px 8px; font-size: 0.75em;
opacity: 0; transition: opacity 0.15s; margin-left: 0;
border-color: var(--border); color: var(--fg);
}
.conv-rename:hover { border-color: var(--accent); color: var(--accent); }
.conv-item:hover .conv-rename { opacity: 1; }
/* "No chat selected" placeholder shown in the chat pane when no
* conversation is active. */
.no-chat-placeholder {
color: var(--muted); font-style: italic; font-size: 0.9em;
padding: 16px; text-align: center;
}
#skill-list {
flex: 1; overflow-y: auto; display: flex;
flex-direction: column; gap: 4px;
}
/* Skills list items — match ai.html skills styling:
* checkbox + skill name + tool badges, with delete on hover for
* user-authored skills. */
.skill-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: var(--radius); flex-wrap: wrap;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.skill-item:hover {
background: rgba(128,128,128,0.06);
border-color: var(--border);
}
.skill-checkbox { flex-shrink: 0; margin: 0; min-width: auto; }
.skill-name { font-size: 0.88em; flex-shrink: 0; color: var(--fg); }
.skill-badge {
font-size: 0.7em; padding: 1px 6px; border-radius: 3px;
white-space: nowrap;
}
.skill-badge-tools {
background: rgba(0,180,0,0.15); color: #2a2;
border: 1px solid rgba(0,180,0,0.4);
}
html.dark .skill-badge-tools {
background: rgba(0,180,0,0.2); color: #6c6;
border-color: rgba(0,180,0,0.5);
}
.skill-tool-enabled { border-left: 3px solid rgba(0,180,0,0.5); }
.skill-item .conv-del {
opacity: 0; padding: 1px 6px; font-size: 0.75em;
}
.skill-item:hover .conv-del { opacity: 1; }
/* ── Skill editor (inline, matching ai.html) ──────────────────── */
.skill-badge-unsaved {
background: rgba(255,165,0,0.15); color: #a60;
border: 1px solid rgba(255,165,0,0.4);
}
html.dark .skill-badge-unsaved {
background: rgba(255,165,0,0.2); color: #fc6;
border-color: rgba(255,165,0,0.5);
}
.skillStackedItem {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 8px; margin-bottom: 4px;
background: rgba(128,128,128,0.04);
}
.skillStackedItem > summary {
display: flex; align-items: center; gap: 6px;
cursor: pointer; flex-wrap: wrap; list-style: none;
user-select: none;
}
.skillStackedItem > summary::-webkit-details-marker { display: none; }
.skillStackedItem > summary .skill-checkbox {
flex-shrink: 0; margin: 0; min-width: auto;
}
.skillStackedItem > summary .skill-name {
font-size: 0.88em; flex-shrink: 0; color: var(--fg);
}
.aiSkillControlsRow {
display: flex; flex-direction: column; gap: 6px;
margin-top: 8px; padding-top: 6px;
border-top: 1px solid var(--border);
}
.aiSkillInlineField {
display: flex; flex-direction: column; gap: 2px;
}
.aiSkillInlineField label {
font-size: 0.75em; color: var(--muted); font-weight: bold;
}
.aiInput {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
min-width: 0; width: 100%; box-sizing: border-box;
}
.aiInput:focus { border-color: var(--accent); outline: none; }
.taSkillTemplateEditor {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 6px; font-family: var(--font); font-size: 0.85em;
width: 100%; box-sizing: border-box; min-height: 80px;
resize: vertical;
}
.taSkillTemplateEditor:focus { border-color: var(--accent); outline: none; }
.aiSkillActionsRow {
display: flex; gap: 6px; margin-top: 6px;
}
.aiSkillActionsRow .btn { margin-left: 0; }
.aiSkillActionsRow .btn:disabled { opacity: 0.5; cursor: not-allowed; }
#create-skill-form {
display: flex; flex-direction: column; gap: 4px;
margin-top: 4px; padding: 8px;
border: 1px solid var(--border); border-radius: var(--radius);
background: rgba(128,128,128,0.05);
}
.skill-input {
width: 100%; box-sizing: border-box;
font-family: var(--font); font-size: 0.85em; padding: 4px 6px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg);
}
#skill-content { resize: vertical; min-height: 60px; }
/* The chat tab content fills the available height: messages scroll,
* input pinned to the bottom. */
#tab-chat { min-width: 0; }
#messages {
flex: 1; overflow-y: auto; border: 1px solid #444;
padding: 8px; margin-bottom: 8px; background: rgba(0,0,0,0.05);
}
/* ── Message bubbles (ported from ai.html messaging-ui.css) ────── */
.msg-bubble-row {
display: flex; width: 100%;
}
.msg-bubble-row.outgoing { justify-content: flex-end; }
.msg-bubble-row.incoming,
.msg-bubble-row.system { justify-content: flex-start; }
.msg-bubble {
max-width: 75%;
border: 1px solid var(--primary);
border-radius: 10px;
padding: 8px 10px;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 90%;
color: var(--fg);
background: var(--bg);
margin-bottom: 10px;
}
.msg-bubble.outgoing { border-color: var(--primary); }
.msg-bubble.incoming { border-color: var(--muted); }
.msg-bubble.system {
border-style: dashed;
border-color: var(--muted);
color: var(--muted);
font-size: 82%;
}
.msg-bubble-header {
display: flex; align-items: center;
justify-content: space-between; gap: 8px; margin-bottom: 6px;
}
.msg-bubble-header-main {
min-width: 0; flex: 1; display: flex;
align-items: center; gap: 8px;
}
.msg-bubble-sender-meta {
min-width: 0; display: flex; flex-direction: column; gap: 1px;
}
.msg-bubble-sender-name {
font-size: 78%; color: var(--fg); font-weight: bold;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.msg-bubble-menu-host { flex-shrink: 0; }
.msg-bubble-content { line-height: 1.35; }
.msg-bubble-content > *:first-child { margin-top: 0; }
.msg-bubble-content > *:last-child { margin-bottom: 0; }
.msg-bubble-content p { margin: 0 0 0.3em 0; }
.msg-bubble-content h1, .msg-bubble-content h2, .msg-bubble-content h3,
.msg-bubble-content h4, .msg-bubble-content h5, .msg-bubble-content h6 {
margin: 0.4em 0 0.2em 0; font-size: 1em;
}
.msg-bubble-content code {
background: rgba(128,128,128,0.18);
border-radius: 4px; padding: 1px 4px; font-size: 85%;
}
.msg-bubble-content pre {
background: rgba(0,0,0,0.15);
border-radius: 6px; padding: 6px 8px; margin: 4px 0;
overflow-x: auto;
}
.msg-bubble-content pre code {
background: transparent; padding: 0;
border-radius: 0; font-size: 90%;
}
.msg-bubble-content blockquote {
margin: 4px 0; padding: 2px 8px;
border-left: 3px solid var(--muted);
color: var(--muted);
}
.msg-bubble-content a {
color: var(--accent); text-decoration: underline; word-break: break-all;
}
.msg-bubble-content img {
display: block; max-width: 100%;
border-radius: 6px; margin: 4px 0;
}
.msg-bubble-content ul, .msg-bubble-content ol {
margin: 4px 0; padding-left: 20px;
}
.msg-bubble-content table {
width: 100%; border-collapse: collapse;
margin: 6px 0; font-size: 90%;
}
.msg-bubble-content th, .msg-bubble-content td {
border: 1px solid var(--border);
padding: 4px 8px; text-align: left; vertical-align: top;
}
.msg-bubble-content thead th { background: rgba(128,128,128,0.12); }
.msg-bubble-content tbody tr:nth-child(even) { background: rgba(128,128,128,0.06); }
.msg-bubble-content del, .msg-bubble-content s { opacity: 0.75; }
.msg-bubble-content strong { font-weight: bold; }
.msg-tcid { font-size: 0.8em; color: var(--muted); margin-top: 4px; }
/* ── Dot-menu ────────────────────────────────────────────────────
* The dot-menu styles now come from the shared sovereign://css/dot-menu.css
* (copied from the client). Only the host sizing is page-specific. */
.msg-bubble-menu-host .dot-menu-wrap { flex-shrink: 0; }
/* ── Tool calls, status message, input area ───────────────────── */
.tool-calls { margin-top: 4px; }
.tool-calls pre {
font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word;
}
/* Status message — a temporary system bubble shown in the chat window
* (replaces the old #status-bar). It is italic and muted to distinguish
* it from real agent responses. */
#status-msg {
margin-bottom: 8px;
}
#status-msg-bubble {
font-style: italic; color: var(--muted);
max-width: 90%; border-style: dashed;
}
/* The input area is a flex row: the composer (textarea + send button)
* on the left taking all available space, and the Stop button on the
* right (only visible while the agent is running). */
#input-area {
display: flex; align-items: flex-end; gap: 8px;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px; background: var(--bg);
}
#composer-host {
flex: 1; min-width: 0;
}
/* The shared post-composer library wraps the host element: it inserts a
* .post-composer-wrapper around #composer-host and appends the send
* button as a SIBLING of the host (inside the wrapper). So the send
* button is NOT a descendant of #composer-host — it lives in
* #input-area > .post-composer-wrapper > [.post-composer-editor-shell >
* #composer-host, .post-composer-send-btn]. Target it via #input-area.
* Make the wrapper fill the row and ensure the send button has a
* visible border matching the page .btn style (post-composer.css uses
* --primary-color which is undefined here, so we override it). */
#input-area .post-composer-wrapper {
width: 100%; min-width: 0; max-width: none; margin-bottom: 0;
border: none; padding: 0; background: transparent;
display: flex; flex-direction: column; gap: 0;
}
#input-area .post-composer-send-btn {
margin-left: 0; min-width: 80px;
border: 1px solid var(--primary);
color: var(--primary);
}
#input-area .post-composer-send-btn:hover,
#input-area .post-composer-send-btn:focus {
border-color: var(--accent);
}
#input-area .post-composer-send-btn:disabled {
opacity: 0.5; cursor: not-allowed;
}
/* Stop-mode: the send button transforms into a stop button while the
* agent is running. Use a distinct (red-ish) style to signal that
* clicking it will cancel the agent. */
#input-area .post-composer-send-btn.is-stop-mode {
border-color: var(--danger, #c33);
color: var(--danger, #c33);
background: transparent;
}
#input-area .post-composer-send-btn.is-stop-mode:hover,
#input-area .post-composer-send-btn.is-stop-mode:focus {
background: var(--danger, #c33);
color: #fff;
border-color: var(--danger, #c33);
}
/* ── Responsive: narrow sidebar viewport (< 400px) ──────────────
* When the chat page is loaded in the agent sidebar (~350px wide),
* switch to a compact layout: smaller fonts, shorter tab labels,
* full-width messages, tighter padding. */
@media (max-width: 400px) {
#chat-root { padding: 4px; }
.tab {
padding: 6px 8px; font-size: 12px;
}
/* Shorten tab labels to single characters in narrow view. */
.tab[data-tab="chat"] { font-size: 0; }
.tab[data-tab="chat"]::before { content: "💬"; font-size: 14px; }
.tab[data-tab="conversations"] { font-size: 0; }
.tab[data-tab="conversations"]::before { content: "☰"; font-size: 14px; }
.tab[data-tab="skills"] { font-size: 0; }
.tab[data-tab="skills"]::before { content: "⚙"; font-size: 14px; }
#messages {
padding: 4px; font-size: 12px;
}
.msg-bubble {
max-width: 92%; font-size: 85%; padding: 6px 8px;
}
.msg-bubble.system { font-size: 78%; }
#input-area { padding: 4px; gap: 4px; }
.tab-toolbar { gap: 4px; }
.tab-toolbar .btn { padding: 4px 10px; font-size: 12px; }
.conv-item { padding: 6px 8px; }
.conv-title { font-size: 0.85em; }
.skill-name { font-size: 0.82em; }
.skill-input { font-size: 12px; }
}
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Chat</title>
<link rel="stylesheet" href="sovereign://css/dot-menu.css">
<link rel="stylesheet" href="sovereign://css/post-composer.css">
<link rel="stylesheet" href="sovereign://agents/chat.css">
</head>
<body>
<div id="chat-root">
<div id="tab-bar">
<button class="tab active" data-tab="chat" onclick="switchTab('chat')">Chat</button>
<button class="tab" data-tab="conversations" onclick="switchTab('conversations')">Conversations</button>
<button class="tab" data-tab="skills" onclick="switchTab('skills')">Skills</button>
</div>
<div id="tab-chat" class="tab-content active">
<div id="messages"></div>
<div id="no-chat-placeholder" class="no-chat-placeholder">
No conversations yet. Click &lsquo;+ New Chat&rsquo; to start.
</div>
<div id="status-msg" class="msg-bubble-row system" style="display:none">
<div class="msg-bubble system" id="status-msg-bubble"></div>
</div>
<div id="input-area">
<div id="composer-host"></div>
</div>
</div>
<div id="tab-conversations" class="tab-content">
<div class="tab-toolbar">
<button id="new-chat-btn" class="btn" onclick="newChat()">+ New Chat</button>
<button class="btn" onclick="fetchConvList()" title="Refresh">&#8635;</button>
</div>
<div id="conv-list"></div>
</div>
<div id="tab-skills" class="tab-content">
<div class="tab-toolbar">
<button id="create-skill-btn" class="btn" onclick="showCreateSkillForm()">+ Create Skill</button>
<button class="btn" onclick="fetchSkills()" title="Refresh">&#8635;</button>
</div>
<div id="skill-list"></div>
<div id="create-skill-form" style="display:none">
<input id="skill-name" placeholder="Skill name" class="skill-input">
<input id="skill-desc" placeholder="Description (optional)" class="skill-input">
<textarea id="skill-content" rows="4" placeholder="System prompt template..." class="skill-input"></textarea>
<input id="skill-tools" placeholder="Tools (comma-separated, optional)" class="skill-input">
<button class="btn" onclick="publishSkill()">Publish</button>
<button class="btn btn-del" onclick="hideCreateSkillForm()">Cancel</button>
</div>
</div>
</div>
<script src="sovereign://vendor/marked.min.js"></script>
<script src="sovereign://vendor/purify.min.js"></script>
<script src="sovereign://js/dot-menu.js"></script>
<script src="sovereign://js/post-composer.js"></script>
<script src="sovereign://agents/chat.js"></script>
</body>
</html>
+1017
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
/* Agent config page — sovereign://agents
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent</title>
<link rel="stylesheet" href="sovereign://agents/config.css">
</head>
<body>
<h1>Agent</h1>
<p class="note">
Configure the LLM provider for the embedded agent. Providers are
shared across all apps (sovereign_browser, client, didactyl) via the
Nostr <code>d:user-settings</code> event. The model, system prompt,
and max iterations are per-app. Type a semicolon then a message in
the URL bar to talk to the agent.
</p>
<h2>Providers (shared)</h2>
<div class="field">
<div><div class="setting-name">Active provider</div></div>
<div><select id="agent.provider" onchange="onProviderChange()"></select></div>
</div>
<div class="field">
<div><div class="setting-name">Provider name</div></div>
<div><input type="text" id="provider_name" size="30">
<button class="save-btn" onclick="saveProviderName()">Rename</button></div>
</div>
<div class="field">
<div><div class="setting-name">Base URL</div></div>
<div><input type="text" id="agent.llm_base_url" size="50">
<button class="save-btn" onclick="saveAndRefresh('agent.llm_base_url')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">API Key</div></div>
<div><input type="text" id="agent.llm_api_key" size="50">
<label style="margin-left:4px;font-size:12px">
<input type="checkbox" id="api-key-hide"
onchange="toggleKeyHide()">Hide</label>
<button class="save-btn" onclick="saveAndRefresh('agent.llm_api_key')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Manage providers</div></div>
<div><input type="text" id="new_provider_name" size="20"
placeholder="new provider name">
<button class="save-btn" onclick="addProvider()">Add</button>
<button class="btn btn-del" onclick="removeProvider()">Remove Active</button></div>
</div>
<h2>Agent Config (per-app)</h2>
<div class="field">
<div><div class="setting-name">Model</div></div>
<div><select id="agent.llm_model" onchange="onModelChange()"></select>
<input type="text" id="agent.llm_model_text" size="30"
style="display:none;margin-left:4px"
oninput="onModelTextInput()">
<button id="refresh-models-btn" class="save-btn"
onclick="refreshModels()">Refresh Models</button></div>
</div>
<div class="field">
<div><div class="setting-name">Max iterations</div></div>
<div><input type="number" id="agent.max_iterations" min="1" max="10000">
<button class="save-btn" onclick="save('agent.max_iterations')">Save</button></div>
</div>
<h2>Sovereign Browser Skill</h2>
<p class="note">
The system prompt is treated as the default skill for sovereign_browser.
Edit it here, then click "Save as Skill" to publish it to Nostr as a
kind 31123 event (shareable with client, didactyl, and other apps).
</p>
<div class="field">
<div><div class="setting-name">Name</div></div>
<div><input type="text" id="agent.skill_name" size="50">
<button class="save-btn" onclick="save('agent.skill_name')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Description</div></div>
<div><input type="text" id="agent.skill_description" size="70">
<button class="save-btn" onclick="save('agent.skill_description')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Template (system prompt)</div></div>
<div><textarea id="agent.skill_template" rows="8" cols="70"></textarea>
<button class="save-btn" onclick="save('agent.skill_template')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Requires tools (comma-separated)</div></div>
<div><input type="text" id="agent.skill_requires_tools" size="50">
<button class="save-btn" onclick="save('agent.skill_requires_tools')">Save</button>
<button class="save-btn" onclick="saveAsSkill()">Save as Skill</button></div>
</div>
<p><a href="sovereign://agents/chat" target="_blank">Open Agent Chat</a></p>
<div id="status" class="status"></div>
<script src="sovereign://agents/config.js"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
/* Agent config page — sovereign://agents
*
* Fetches the current agent config from sovereign://agents/config (JSON)
* on page load and populates the form. Saves use sovereign://agents/set.
* Model refresh uses sovereign://agents/models.
*/
var savedModel = '';
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function showStatus(cls, msg) {
var s = document.getElementById('status');
s.className = 'status ' + cls + ' show';
s.textContent = msg;
}
function toggleKeyHide() {
var cb = document.getElementById('api-key-hide');
var ak = document.getElementById('agent.llm_api_key');
ak.type = cb.checked ? 'password' : 'text';
}
function applyConfig(d) {
/* Provider dropdown. */
var sel = document.getElementById('agent.provider');
sel.innerHTML = '';
(d.providers || []).forEach(function(name, i) {
var opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (i === d.active_provider) opt.selected = true;
sel.appendChild(opt);
});
/* Active provider fields. */
document.getElementById('provider_name').value = d.provider_name || '';
document.getElementById('agent.llm_base_url').value = d.base_url || '';
document.getElementById('agent.llm_api_key').value = d.api_key || '';
/* Model dropdown. */
savedModel = d.model || '';
populateModels(d.models || []);
/* Max iterations + Sovereign Browser Skill fields. */
document.getElementById('agent.max_iterations').value =
d.max_iterations != null ? d.max_iterations : 100;
document.getElementById('agent.skill_name').value =
d.skill_name || 'Sovereign Browser Default';
document.getElementById('agent.skill_description').value =
d.skill_description || 'Default agent skill for sovereign_browser';
document.getElementById('agent.skill_template').value =
d.skill_template || d.system_prompt || '';
document.getElementById('agent.skill_requires_tools').value =
d.skill_requires_tools || 'browser, fs, shell';
updateRefreshBtn();
}
function save(key) {
var el = document.getElementById(key);
var val = el ? el.value : '';
return sovereignGet('sovereign://agents/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(val))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else showStatus('ok', d.key + ' saved');
return d;
})
.catch(function(e) {
showStatus('err', 'Error: ' + e.message);
throw e;
});
}
function saveAndRefresh(key) {
save(key).then(function() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
if (bu && bu.value && bu.value.trim() && ak && ak.value && ak.value.trim()) {
refreshModels();
}
}).catch(function() {});
}
function updateRefreshBtn() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
var btn = document.getElementById('refresh-models-btn');
var enabled = bu && bu.value && bu.value.trim() &&
ak && ak.value && ak.value.trim();
btn.disabled = !enabled;
}
function onProviderChange() {
save('agent.provider').then(function() { window.location.reload(); });
}
function saveProviderName() {
var el = document.getElementById('provider_name');
if (el && el.value && el.value.trim()) {
save('agent.provider_name').then(function() { window.location.reload(); });
}
}
function addProvider() {
var el = document.getElementById('new_provider_name');
if (el && el.value && el.value.trim()) {
sovereignGet('sovereign://agents/set?key=agent.provider_add&value=' +
encodeURIComponent(el.value.trim()))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else { showStatus('ok', 'Provider added'); window.location.reload(); }
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
}
function removeProvider() {
var sel = document.getElementById('agent.provider');
if (sel && sel.value) {
if (!confirm('Remove provider "' + sel.value + '"?')) return;
sovereignGet('sovereign://agents/set?key=agent.provider_remove&value=' +
encodeURIComponent(sel.value))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else { showStatus('ok', 'Provider removed'); window.location.reload(); }
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
}
function onModelChange() {
var sel = document.getElementById('agent.llm_model');
var txt = document.getElementById('agent.llm_model_text');
if (sel.value === '__custom__') {
txt.style.display = '';
txt.value = savedModel;
txt.focus();
} else {
txt.style.display = 'none';
savedModel = sel.value;
save('agent.llm_model');
}
}
function onModelTextInput() {
var txt = document.getElementById('agent.llm_model_text');
savedModel = txt.value;
}
function saveCustomModel() {
var txt = document.getElementById('agent.llm_model_text');
if (txt && txt.value && txt.value.trim()) {
savedModel = txt.value.trim();
save('agent.llm_model');
}
}
function populateModels(models) {
var sel = document.getElementById('agent.llm_model');
var txt = document.getElementById('agent.llm_model_text');
sel.innerHTML = '';
models = models.slice().sort(function(a, b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
var found = false;
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
opt.textContent = models[i];
if (models[i] === savedModel) { opt.selected = true; found = true; }
sel.appendChild(opt);
}
var copt = document.createElement('option');
copt.value = '__custom__';
copt.textContent = '(custom...)';
if (!found) { copt.selected = true; sel.value = '__custom__'; }
sel.appendChild(copt);
if (!found) {
txt.style.display = '';
txt.value = savedModel;
} else {
txt.style.display = 'none';
}
}
function refreshModels() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
if (!bu || !bu.value || !bu.value.trim()) return;
var btn = document.getElementById('refresh-models-btn');
btn.disabled = true;
btn.textContent = 'Loading...';
var url = 'sovereign://agents/models?base_url=' +
encodeURIComponent(bu.value.trim()) +
'&api_key=' + encodeURIComponent(ak ? ak.value : '');
sovereignGet(url)
.then(function(d) {
if (d && d.error) {
showStatus('err', 'Models: ' + d.error);
} else if (d && d.length) {
populateModels(d);
showStatus('ok', 'Loaded ' + d.length + ' models');
} else {
showStatus('err', 'No models returned');
}
})
.catch(function(e) { showStatus('err', 'Models error: ' + e.message); })
.then(function() {
btn.textContent = 'Refresh Models';
updateRefreshBtn();
});
}
/* ── Save as Skill (publish to Nostr as kind 31123) ──────────── */
function saveAsSkill() {
var name = document.getElementById('agent.skill_name').value.trim();
var desc = document.getElementById('agent.skill_description').value.trim();
var content = document.getElementById('agent.skill_template').value.trim();
var toolsRaw = document.getElementById('agent.skill_requires_tools').value.trim();
if (!name || !content) {
showStatus('err', 'Name and template are required');
return;
}
var tools = [];
if (toolsRaw) {
tools = toolsRaw.split(',').map(function(t) {
return t.trim();
}).filter(function(t) { return t.length > 0; });
}
/* Save the local fields first, then publish to Nostr. */
save('agent.skill_name').then(function() {
return save('agent.skill_description');
}).then(function() {
return save('agent.skill_template');
}).then(function() {
return save('agent.skill_requires_tools');
}).then(function() {
var url = 'sovereign://agents/skills/create?name='
+ encodeURIComponent(name)
+ '&description=' + encodeURIComponent(desc)
+ '&content=' + encodeURIComponent(content)
+ '&requires_tools=' + encodeURIComponent(JSON.stringify(tools));
return sovereignGet(url);
}).then(function(d) {
if (d.error) {
showStatus('err', 'Publish failed: ' + d.message);
} else {
showStatus('ok', 'Skill published to Nostr (d: ' + d.d + ')');
}
}).catch(function(e) {
showStatus('err', 'Error: ' + e.message);
});
}
/* ── Init ───────────────────────────────────────────────────────── */
sovereignGet('sovereign://agents/config?_=' + Date.now())
.then(function(d) { applyConfig(d); })
.catch(function(e) { showStatus('err', 'Failed to load config: ' + e.message); });
document.addEventListener('DOMContentLoaded', function() {
var bu = document.getElementById('agent.llm_base_url');
var ak = document.getElementById('agent.llm_api_key');
var txt = document.getElementById('agent.llm_model_text');
if (bu) bu.addEventListener('input', updateRefreshBtn);
if (ak) ak.addEventListener('input', updateRefreshBtn);
if (txt) txt.addEventListener('change', saveCustomModel);
});
/* After config loads, auto-refresh models if base URL + API key are set. */
sovereignGet('sovereign://agents/config?_=' + Date.now())
.then(function(d) {
if (d.base_url && d.base_url.trim() && d.api_key && d.api_key.trim()) {
refreshModels();
}
})
.catch(function() {});
+93
View File
@@ -0,0 +1,93 @@
/* Bookmarks page — sovereign://bookmarks
* Imports the shared sovereign:// base theme, then adds bookmarks-specific
* layout. The base CSS lives in www/sovereign-base.css and is served at
* sovereign://sovereign-base.css. */
@import url("sovereign://sovereign-base.css");
/* ── Tree view ─────────────────────────────────────────────────────── */
.tree-node {
margin: 2px 0;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.caret {
display: inline-block;
width: 14px;
cursor: pointer;
user-select: none;
color: var(--primary);
font-size: 10px;
transition: transform 0.15s ease;
transform: rotate(0deg);
flex-shrink: 0;
}
.caret.expanded {
transform: rotate(90deg);
}
.folder-icon {
font-size: 16px;
flex-shrink: 0;
}
.folder-name {
font-weight: bold;
color: var(--primary);
flex-shrink: 0;
}
.folder-actions {
display: flex;
gap: 6px;
margin-left: auto;
flex-wrap: wrap;
}
/* Smaller buttons in folder actions so the row stays compact. */
.folder-actions .btn {
padding: 3px 10px;
font-size: 11px;
margin-left: 0;
}
/* Ensure every .btn renders the themed 1px solid border. WebKitGTK's UA
* stylesheet gives <button> a native 2px outset grey border tied to
* -webkit-appearance: button. Setting appearance: none strips the native
* chrome so the author border applies. The !important guards against the
* UA !important on border-width/style/color. */
.btn, .save-btn, button.btn {
-webkit-appearance: none !important;
appearance: none !important;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--primary) !important;
border-radius: var(--radius);
display: inline-block;
text-decoration: none;
}
.btn:hover, .save-btn:hover, button.btn:hover {
border-color: var(--accent) !important;
text-decoration: none;
}
.btn-del { border-color: var(--accent) !important; color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
.tree-children {
border-left: 1px dashed var(--border);
padding-left: 4px;
}
/* Reuse .bm / .bm-title / .bm-url / .bm-meta / .actions / .btn / .btn-del
* from the base theme for bookmark rows. */
#bm-path, #new-path { min-width: 200px; }
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bookmarks</title>
<link rel="stylesheet" href="sovereign://bookmarks.css">
</head>
<body>
<h1>Bookmarks</h1>
<p id="readonly-note" class="note" style="display:none">
Read-only mode — sign in to add, edit, or delete bookmarks.
Showing cached bookmarks from the local database.
</p>
<div id="add-form" style="display:none">
<h2>Add Bookmark</h2>
<div class="form">
<input type="text" id="bm-url" placeholder="URL" size="40">
<input type="text" id="bm-title" placeholder="Title" size="25">
<input type="text" id="bm-path" placeholder="Folder path (e.g. Work/Projects)" size="25">
<button class="btn" onclick="addBookmark()">Add</button>
</div>
<div class="form">
<input type="text" id="new-path" placeholder="New folder path (e.g. Work/Projects/Secret)" size="35">
<button class="btn" onclick="createDir()">Create Folder</button>
</div>
</div>
<h2>Bookmarks</h2>
<div id="bm-tree"></div>
<script src="sovereign://bookmarks.js"></script>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
/* Bookmarks page — sovereign://bookmarks
*
* Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and
* renders it as an expandable/collapsible tree. Folders may be nested
* arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename
* use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir}
* endpoints.
*
* Expand/collapse state is persisted in localStorage so the user's
* expanded folders survive page reloads.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
/* ── Expand/collapse state (persisted in localStorage) ─────────────── */
var EXPANDED_KEY = 'sovereign_bookmarks_expanded';
function getExpanded() {
try {
var raw = localStorage.getItem(EXPANDED_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) { return {}; }
}
function setExpanded(path, isExpanded) {
var state = getExpanded();
if (isExpanded) state[path] = true;
else delete state[path];
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); }
catch (e) { /* ignore quota errors */ }
}
/* ── Tree rendering ────────────────────────────────────────────────── */
function renderTree(data) {
var haveSigner = !!data.have_signer;
document.getElementById('readonly-note').style.display =
haveSigner ? 'none' : '';
document.getElementById('add-form').style.display =
haveSigner ? '' : 'none';
var c = document.getElementById('bm-tree');
var tree = data.tree;
if (!tree || !tree.children || tree.children.length === 0) {
c.innerHTML =
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
'in the toolbar to save a page.</p>';
return;
}
c.innerHTML = '';
(tree.children || []).forEach(function(child) {
c.appendChild(renderNode(child, haveSigner, 0));
});
}
function renderNode(node, haveSigner, depth) {
/* A folder row + its bookmark list + its children (recursive). */
var expandedState = getExpanded();
var isExpanded = !!expandedState[node.path];
var wrap = document.createElement('div');
wrap.className = 'tree-node';
/* Folder row. */
var row = document.createElement('div');
row.className = 'folder-row';
var caret = document.createElement('span');
caret.className = 'caret' + (isExpanded ? ' expanded' : '');
caret.textContent = '\u25B6'; /* right-pointing triangle */
caret.onclick = function() {
var nowExpanded = caret.classList.toggle('expanded');
childList.style.display = nowExpanded ? '' : 'none';
setExpanded(node.path, nowExpanded);
};
row.appendChild(caret);
var icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = '\u{1F4C1}'; /* folder emoji */
row.appendChild(icon);
var name = document.createElement('span');
name.className = 'folder-name';
name.textContent = node.name + ' (' + node.count + ')';
row.appendChild(name);
/* Per-folder actions. */
var actions = document.createElement('span');
actions.className = 'folder-actions';
if (haveSigner) {
var addHere = document.createElement('a');
addHere.className = 'btn';
addHere.href = '#';
addHere.textContent = 'Add Here';
addHere.onclick = function(e) {
e.preventDefault();
document.getElementById('bm-path').value = node.path;
document.getElementById('bm-url').focus();
};
actions.appendChild(addHere);
var newSub = document.createElement('a');
newSub.className = 'btn';
newSub.href = '#';
newSub.textContent = 'New Subfolder';
newSub.onclick = function(e) {
e.preventDefault();
var sub = prompt('New subfolder name under "' + node.path + '/":');
if (sub && sub.trim()) {
var newPath = node.path + '/' + sub.trim();
location.href = 'sovereign://bookmarks/createdir?name=' +
encodeURIComponent(newPath);
}
};
actions.appendChild(newSub);
if (node.path !== 'General' && node.path !== 'Bookmarks Bar') {
var rename = document.createElement('a');
rename.className = 'btn';
rename.href = '#';
rename.textContent = 'Rename';
rename.onclick = function(e) {
e.preventDefault();
var newName = prompt('Rename folder "' + node.path + '" to:',
node.path);
if (newName && newName.trim() && newName.trim() !== node.path) {
location.href = 'sovereign://bookmarks/renamedir?old=' +
encodeURIComponent(node.path) + '&new=' +
encodeURIComponent(newName.trim());
}
};
actions.appendChild(rename);
var del = document.createElement('a');
del.className = 'btn btn-del';
del.href = '#';
del.textContent = 'Delete';
del.onclick = function(e) {
e.preventDefault();
if (confirm('Delete folder "' + node.path + '"? ' +
'Bookmarks will be moved to General.')) {
location.href = 'sovereign://bookmarks/deletedir?dir=' +
encodeURIComponent(node.path);
}
};
actions.appendChild(del);
}
}
row.appendChild(actions);
wrap.appendChild(row);
/* Child list: bookmarks + subfolders. */
var childList = document.createElement('div');
childList.className = 'tree-children';
childList.style.display = isExpanded ? '' : 'none';
childList.style.marginLeft = '20px';
/* Bookmarks in this folder. */
(node.bookmarks || []).forEach(function(bm) {
childList.appendChild(renderBookmark(bm, node, haveSigner));
});
/* Subfolders. */
(node.children || []).forEach(function(child) {
childList.appendChild(renderNode(child, haveSigner, depth + 1));
});
wrap.appendChild(childList);
return wrap;
}
function renderBookmark(bm, folder, haveSigner) {
var row = document.createElement('div');
row.className = 'bm';
var info = document.createElement('div');
var title = document.createElement('div');
title.className = 'bm-title';
title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)';
info.appendChild(title);
var urlLink = document.createElement('div');
var a = document.createElement('a');
a.className = 'bm-url';
a.href = bm.url;
a.textContent = bm.url;
urlLink.appendChild(a);
info.appendChild(urlLink);
var meta = document.createElement('div');
meta.className = 'bm-meta';
meta.textContent = 'Added: ' + bm.added;
info.appendChild(meta);
row.appendChild(info);
if (haveSigner) {
var actions = document.createElement('div');
actions.className = 'actions';
var editBtn = document.createElement('a');
editBtn.className = 'btn';
editBtn.href = '#';
editBtn.textContent = 'Edit';
editBtn.onclick = function(e) {
e.preventDefault();
var curTitle = (bm.title && bm.title.length) ? bm.title : '';
var newTitle = prompt('Edit bookmark name:', curTitle);
if (newTitle === null) return;
var t = newTitle.trim();
if (t === curTitle.trim()) return;
location.href = 'sovereign://bookmarks/rename?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url) + '&title=' +
encodeURIComponent(t);
};
actions.appendChild(editBtn);
var moveBtn = document.createElement('a');
moveBtn.className = 'btn';
moveBtn.href = '#';
moveBtn.textContent = 'Move';
moveBtn.onclick = function(e) {
e.preventDefault();
var dest = prompt('Move bookmark to folder path:', folder.path);
if (dest && dest.trim() && dest.trim() !== folder.path) {
location.href = 'sovereign://bookmarks/move?from=' +
encodeURIComponent(folder.path) + '&to=' +
encodeURIComponent(dest.trim()) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(moveBtn);
var delBtn = document.createElement('a');
delBtn.className = 'btn btn-del';
delBtn.href = '#';
delBtn.textContent = 'Delete';
delBtn.onclick = function(e) {
e.preventDefault();
if (confirm('Delete this bookmark?')) {
location.href = 'sovereign://bookmarks/delete?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(delBtn);
row.appendChild(actions);
}
return row;
}
/* ── Fetch + actions ───────────────────────────────────────────────── */
function fetchBookmarks() {
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('bm-tree').innerHTML =
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
});
}
function addBookmark() {
var url = encodeURIComponent(document.getElementById('bm-url').value);
var title = encodeURIComponent(document.getElementById('bm-title').value);
var path = encodeURIComponent(document.getElementById('bm-path').value);
if (!url) { alert('URL is required'); return; }
location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url +
'&title=' + title;
}
function createDir() {
var name = encodeURIComponent(document.getElementById('new-path').value);
if (!name) { alert('Folder path is required'); return; }
location.href = 'sovereign://bookmarks/createdir?name=' + name;
}
fetchBookmarks();
+142
View File
@@ -0,0 +1,142 @@
.dot-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.dot-menu-trigger {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: var(--avatar-radius);
border: var(--border);
background: var(--secondary-color);
color: var(--muted-color);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-family);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0;
}
.dot-menu-trigger:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
.dot-menu-trigger:focus-visible {
outline: var(--border-width2) solid var(--accent-color);
outline-offset: 1px;
}
.dot-menu-panel {
position: absolute;
top: calc(100% + 5px);
right: 0;
z-index: 200;
display: none;
flex-direction: column;
gap: 2px;
min-width: 180px;
max-width: min(320px, calc(100vw - 16px));
padding: 5px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
}
.dot-menu-panel[data-position='left'] {
left: 0;
right: auto;
}
.dot-menu-panel[data-position='right'] {
right: 0;
left: auto;
}
.dot-menu-panel.is-open {
display: flex;
}
.dot-menu-item,
.dot-menu-submenu-label {
width: 100%;
display: block;
border: none;
border-radius: var(--border-radius);
background: transparent;
color: var(--primary-color);
text-align: left;
font-family: var(--font-family);
font-size: 14px;
line-height: 1.3;
cursor: pointer;
padding: 2px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dot-menu-item:hover,
.dot-menu-submenu-label:hover {
background: var(--background-color);
color: var(--accent-color);
}
.dot-menu-submenu {
position: relative;
margin: 0;
padding: 0;
}
.dot-menu-submenu-label {
list-style: none;
display: block;
}
.dot-menu-submenu-label::-webkit-details-marker {
display: none;
}
.dot-menu-submenu-panel {
position: fixed;
z-index: 260;
display: none;
flex-direction: column;
align-items: stretch;
gap: 4px;
min-width: 180px;
max-width: min(320px, calc(100vw - 16px));
max-height: min(60vh, 420px);
overflow-y: auto;
padding: 5px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
}
.dot-menu-submenu[open] > .dot-menu-submenu-panel {
display: flex;
}
.dot-menu-submenu-panel > .dot-menu-item,
.dot-menu-submenu-panel > .dot-menu-submenu {
flex: 0 0 auto;
}
.dot-menu-submenu-panel > .dot-menu-item,
.dot-menu-submenu-panel > .dot-menu-submenu > .dot-menu-submenu-label {
min-height: 22.17px;
box-sizing: border-box;
}
.dot-menu-divider {
height: var(--border-width);
margin: 2px 4px;
background: var(--muted-color);
}
+321
View File
@@ -0,0 +1,321 @@
.post-composer-wrapper {
width: 60%;
min-width: 300px;
max-width: 800px;
margin-bottom: 20px;
}
.post-composer-wrapper--inline {
width: 100%;
min-width: 0;
max-width: none;
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 0;
}
/* Popup mode — wrapper fills its container, body scrolls, footer sticks */
.post-composer-wrapper--popup {
width: 100%;
min-width: 0;
max-width: none;
margin-bottom: 0;
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
}
.post-composer-popup-body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.post-composer-popup-footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
padding-top: 10px;
border-top: 1px solid var(--muted-color, #ccc);
margin-top: 8px;
}
.post-composer-popup-footer .post-composer-send-btn {
display: block !important;
width: 100%;
margin-top: 0;
}
.post-composer-inline-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 4px 0;
margin: 0;
}
.post-composer-wrapper--inline .post-composer-editor-shell {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.post-composer-wrapper--inline .post-composer-editor-shell [contenteditable='true'] {
width: 100%;
flex: 1;
min-height: 40px;
height: auto;
overflow-y: visible;
display: block;
padding: 8px 10px;
line-height: 1.3;
border: 1px solid var(--primary-color);
border-radius: 8px;
background: var(--secondary-color);
box-sizing: border-box;
}
.post-composer-wrapper--inline .post-composer-send-btn {
width: auto;
min-width: 78px;
height: 40px;
padding: 0 16px;
line-height: 1;
margin-top: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.post-composer-editor-shell {
position: relative;
}
.inlinePostComposerInput {
min-height: 0;
padding: 0;
line-height: 1.4;
}
.post-composer-wrapper [contenteditable='true'] {
width: 100%;
margin-bottom: 0;
border: 1px solid var(--primary-color);
border-radius: 8px;
background: var(--secondary-color);
}
.post-composer-upload-icon {
position: absolute;
right: 10px;
bottom: 10px;
width: 32px;
height: 32px;
border: 1px solid var(--muted-color);
border-radius: 50%;
background: transparent;
color: var(--muted-color);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
padding: 0;
line-height: 0;
}
.post-composer-wrapper--inline .post-composer-upload-icon {
top: 50%;
bottom: auto;
transform: translateY(-50%);
}
.post-composer-upload-icon:hover,
.post-composer-upload-icon:focus,
.post-composer-upload-icon:focus-visible {
color: var(--accent-color);
border-color: var(--accent-color);
background: transparent;
outline: none;
}
.post-composer-upload-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2;
display: block;
}
.post-composer-upload-icon.is-image-icon svg {
transform: translateY(0.5px);
}
.post-composer-drag-overlay {
position: absolute;
inset: 0;
border: 2px dashed var(--accent-color);
border-radius: 10px;
background: color-mix(in srgb, var(--secondary-color) 78%, transparent);
pointer-events: none;
display: none;
align-items: center;
justify-content: center;
text-align: center;
color: var(--accent-color);
font-size: 90%;
z-index: 3;
}
.post-composer-editor-shell.is-dragover .post-composer-drag-overlay {
display: flex;
}
.post-composer-uploading {
position: absolute;
right: 52px;
bottom: 15px;
font-size: 75%;
color: var(--muted-color);
z-index: 2;
}
.post-composer-preview {
margin-top: 10px;
border: 1px solid var(--muted-color);
border-radius: 10px;
padding: 10px 12px;
background: var(--secondary-color);
display: none;
}
.post-composer-wrapper--inline .post-composer-preview {
margin-top: 0;
}
.post-composer-preview.is-visible {
display: block;
}
.post-composer-preview-label {
font-size: 75%;
color: var(--muted-color);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.post-composer-preview-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.post-composer-preview-content img,
.post-composer-preview-content video,
.post-composer-preview-content canvas,
.post-composer-preview-content iframe {
max-width: 100%;
height: auto;
}
.post-composer-send-btn {
width: 100%;
padding: 10px 20px;
border: 2px solid var(--primary-color);
border-radius: 10px;
font-family: var(--font-family);
font-size: 100%;
color: var(--muted-color);
background-color: var(--secondary-color);
cursor: pointer;
text-align: center;
margin-top: 10px;
transition: color 0.2s, border-color 0.2s;
display: none;
}
.post-composer-send-btn.is-visible,
.post-composer-send-btn.is-persistent-visible {
display: block;
}
.post-composer-send-btn:hover,
.post-composer-send-btn:focus,
.post-composer-send-btn:focus-visible {
color: var(--primary-color);
border-color: var(--primary-color);
outline: none;
}
.post-composer-mention-dropdown {
position: fixed;
min-width: 240px;
max-width: 320px;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--muted-color);
border-radius: 8px;
background: var(--secondary-color);
z-index: 50;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.2);
display: none;
}
.post-composer-mention-dropdown.is-visible {
display: block;
}
.post-composer-mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid color-mix(in srgb, var(--muted-color) 35%, transparent);
}
.post-composer-mention-item:last-child {
border-bottom: none;
}
.post-composer-mention-item:hover,
.post-composer-mention-item.is-active {
background: color-mix(in srgb, var(--secondary-color) 70%, var(--accent-color));
}
.post-composer-mention-avatar {
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.post-composer-mention-meta {
min-width: 0;
}
.post-composer-mention-name {
color: var(--primary-color);
font-size: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-composer-mention-handle {
color: var(--muted-color);
font-size: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+193
View File
@@ -0,0 +1,193 @@
/* FIPS management page — sovereign://fips
* Imports the shared sovereign:// base theme, then adds FIPS-specific
* layout for status cards, the peers table, and the add-peer form. */
@import url("sovereign://sovereign-base.css");
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin: 10px 0 20px;
}
.card .actions {
margin-top: 12px;
}
.mono {
font-family: var(--font);
word-break: break-all;
}
.err-text {
color: var(--accent);
}
/* State indicator dot. */
.state-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
background: var(--muted);
}
.state-dot.ready { background: var(--primary); }
.state-dot.failed { background: var(--accent); }
.state-dot.starting,
.state-dot.stopping,
.state-dot.bootstrapping,
.state-dot.discovering,
.state-dot.attaching { background: var(--accent); animation: pulse 1s infinite; }
/* Peers table. */
#peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#peers-table th,
#peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#peers-table td.mono { word-break: break-all; }
.peer-state-connected { color: var(--primary); }
.peer-state-connecting,
.peer-state-disconnected { color: var(--muted); }
.peer-state-failed { color: var(--accent); }
#refresh-btn { margin-left: 12px; }
/* Tab navigation. */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Known-nodes / tree-peers tables. */
#nodes-table, #tree-peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#nodes-table th, #nodes-table td,
#tree-peers-table th, #tree-peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#nodes-table th, #tree-peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#nodes-table td.mono, #tree-peers-table td.mono { word-break: break-all; }
/* Direct-peer badge in the known-nodes table. */
.peer-badge {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--primary);
}
.peer-badge.indirect { background: var(--muted); }
/* Staleness coloring for last-seen. */
.last-seen-fresh { color: var(--primary); }
.last-seen-stale { color: var(--muted); }
.last-seen-old { color: var(--muted); font-style: italic; }
/* Tree role tags. */
.tree-role { font-size: 11px; padding: 1px 6px; border-radius: 3px;
border: 1px solid var(--border); }
.tree-role.root { color: var(--accent); border-color: var(--accent); }
.tree-role.parent { color: var(--primary); }
.tree-role.child { color: var(--muted); }
/* Directory tab — compact one-line rows, wider table, smaller font. */
body.wide { max-width: 1400px; }
#tab-directory { max-width: 100%; }
#dir-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
table-layout: fixed;
}
#dir-table th, #dir-table td {
text-align: left;
padding: 3px 8px;
border-bottom: 1px solid var(--border);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#dir-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
/* Column widths: badge | npub | endpoint | connect */
#dir-table th:nth-child(1), #dir-table td:nth-child(1) { width: 20px; }
#dir-table th:nth-child(3), #dir-table td:nth-child(3) { width: 35%; }
#dir-table th:nth-child(4), #dir-table td:nth-child(4) { width: 80px; }
#dir-table td.mono {
font-family: var(--font);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Pinned "this is you" row — highlighted border. */
#dir-table tr.dir-ours-row {
border: 2px solid var(--accent);
background: rgba(255, 0, 0, 0.05);
}
#dir-table tr.dir-ours-row td {
border-bottom: 2px solid var(--accent);
font-weight: bold;
}
.dir-ours-tag {
color: var(--accent);
font-size: 10px;
font-weight: bold;
border: 1px solid var(--accent);
padding: 1px 4px;
border-radius: 3px;
}
/* Connect as clickable text, not a button. */
.dir-connect-link {
color: var(--accent);
font-size: 11px;
cursor: pointer;
text-decoration: underline;
}
.dir-connect-link:hover { text-decoration: none; }
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FIPS Mesh Network</title>
<link rel="stylesheet" href="sovereign://fips.css">
</head>
<body>
<h1>FIPS Mesh Network <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="status">Status</button>
<button class="tab-btn" data-tab="peers">Peers</button>
<button class="tab-btn" data-tab="network">Network</button>
<button class="tab-btn" data-tab="tree">Tree</button>
<button class="tab-btn" data-tab="directory">Directory</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Status tab -->
<div class="tab-panel active" id="tab-status">
<h2>Status</h2>
<div class="card" id="fips-status">
<div class="info-row"><span>State</span><span id="fips-state">—</span></div>
<div class="info-row"><span>Node</span><span id="fips-node" class="mono">—</span></div>
<div class="info-row"><span>TUN</span><span id="fips-tun">—</span></div>
<div class="info-row"><span>Peers</span><span id="fips-peers">—</span></div>
<div class="info-row"><span>Tree</span><span id="fips-tree">—</span></div>
<div class="info-row"><span>Ownership</span><span id="fips-ownership">—</span></div>
<div class="info-row"><span>Control</span><span id="fips-control" class="mono">—</span></div>
<div class="info-row" id="fips-error-row" style="display:none">
<span>Error</span><span id="fips-error" class="err-text"></span>
</div>
<div class="actions">
<button class="btn" id="restart-btn">Restart Service</button>
</div>
</div>
<h2>Tor Status <span class="note">(read-only)</span></h2>
<div class="card" id="tor-status">
<div class="info-row"><span>State</span><span id="tor-state">—</span></div>
<div class="info-row"><span>Bootstrap</span><span id="tor-bootstrap">—</span></div>
<div class="info-row"><span>SOCKS</span><span id="tor-socks" class="mono">—</span></div>
<div class="info-row"><span>Ownership</span><span id="tor-ownership">—</span></div>
<div class="info-row" id="tor-error-row" style="display:none">
<span>Error</span><span id="tor-error" class="err-text"></span>
</div>
</div>
</div>
<!-- Peers tab -->
<div class="tab-panel" id="tab-peers">
<h2>Peers</h2>
<div id="peers-container">
<p class="empty" id="peers-empty">Loading peers…</p>
<table id="peers-table" style="display:none">
<thead>
<tr>
<th>npub</th><th>address</th><th>transport</th><th>state</th><th></th>
</tr>
</thead>
<tbody id="peers-body"></tbody>
</table>
</div>
<h2>Add Peer</h2>
<div class="card">
<div class="form">
<input type="text" id="peer-npub" placeholder="npub1..." size="50">
<input type="text" id="peer-address" placeholder="host:port" size="30">
<select id="peer-transport">
<option value="udp">udp</option>
<option value="tcp">tcp</option>
</select>
<button class="btn" id="connect-btn">Connect</button>
</div>
</div>
</div>
<!-- Network tab -->
<div class="tab-panel" id="tab-network">
<h2>Mesh Summary</h2>
<div class="card" id="mesh-summary">
<div class="info-row"><span>Known nodes</span><span id="mesh-known">—</span></div>
<div class="info-row"><span>Direct peers</span><span id="mesh-direct">—</span></div>
<div class="info-row"><span>Tree depth</span><span id="mesh-depth">—</span></div>
<div class="info-row"><span>Root</span><span id="mesh-root" class="mono">—</span></div>
<div class="info-row"><span>Our parent</span><span id="mesh-parent">—</span></div>
<div class="info-row" id="mesh-error-row" style="display:none">
<span>Error</span><span id="mesh-error" class="err-text"></span>
</div>
</div>
<h2>Known Nodes <span class="note" id="nodes-count-label"></span></h2>
<div id="nodes-container">
<p class="empty" id="nodes-empty">Loading network…</p>
<table id="nodes-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>name</th><th>ipv6</th><th>last seen</th>
</tr>
</thead>
<tbody id="nodes-body"></tbody>
</table>
</div>
</div>
<!-- Directory tab -->
<div class="tab-panel" id="tab-directory">
<h2>FIPS Node Directory <span class="note" id="dir-count-label"></span></h2>
<p class="note">All nodes that have published a FIPS service advert (Kind 37195) to the Nostr advert relays. This is the global network directory, like join.fips.network.</p>
<div id="dir-container">
<p class="empty" id="dir-empty">Click to load the directory from Nostr relays…</p>
<table id="dir-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>endpoint</th><th></th>
</tr>
</thead>
<tbody id="dir-body"></tbody>
</table>
</div>
<div class="actions" id="dir-actions" style="display:none">
<button class="btn" id="dir-refresh-btn">Reload Directory</button>
</div>
</div>
<!-- Tree tab -->
<div class="tab-panel" id="tab-tree">
<h2>Spanning Tree</h2>
<div class="card" id="tree-summary">
<div class="info-row"><span>Root</span><span id="tree-root" class="mono">—</span></div>
<div class="info-row"><span>Is root</span><span id="tree-is-root">—</span></div>
<div class="info-row"><span>Our depth</span><span id="tree-depth">—</span></div>
<div class="info-row"><span>Our parent</span><span id="tree-parent">—</span></div>
<div class="info-row"><span>Our node addr</span><span id="tree-node-addr" class="mono">—</span></div>
<div class="info-row" id="tree-error-row" style="display:none">
<span>Error</span><span id="tree-error" class="err-text"></span>
</div>
</div>
<h2>Tree Peers <span class="note">(1-hop neighbors)</span></h2>
<div id="tree-peers-container">
<p class="empty" id="tree-peers-empty">Loading tree…</p>
<table id="tree-peers-table" style="display:none">
<thead>
<tr>
<th>node addr</th><th>name</th><th>depth</th><th>distance</th><th>role</th>
</tr>
</thead>
<tbody id="tree-peers-body"></tbody>
</table>
</div>
</div>
<script src="sovereign://fips.js"></script>
</body>
</html>
+564
View File
@@ -0,0 +1,564 @@
/* FIPS management page — sovereign://fips
*
* Tabs: Status | Peers | Network | Tree
*
* - Status: FIPS + Tor status cards (sovereign://fips/status)
* - Peers: direct peers table + add-peer form (sovereign://fips/peers)
* - Network: mesh summary + known-nodes table (sovereign://fips/network)
* - Tree: spanning tree view (sovereign://fips/tree)
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
function stateDotClass(state) {
return 'state-dot ' + (state || '');
}
/* Global: our own FIPS node npub (from show_status), used to pin our
* node in the Directory tab. */
var gOurNpub = '';
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function(panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
/* Widen the page for the directory tab (long npub list needs width). */
document.body.classList.toggle('wide', tabName === 'directory');
/* Load tab data on demand. */
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
if (tabName === 'directory') fetchDirectory();
}
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
});
/* ── Status tab ─────────────────────────────────────────────────── */
function renderFipsStatus(data) {
var f = data.fips || {};
var state = f.state || 'disabled';
/* Store our npub for the Directory tab pinning. */
gOurNpub = f.node_npub || '';
document.getElementById('fips-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('fips-node').textContent = f.node_npub || '—';
var tun = '—';
if (f.tun_name && f.tun_name.length) {
tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)');
}
document.getElementById('fips-tun').textContent = tun;
document.getElementById('fips-peers').textContent =
(f.peer_count != null ? f.peer_count : '—') + ' connected';
document.getElementById('fips-tree').textContent = f.tree_state || '—';
document.getElementById('fips-ownership').textContent =
f.ownership || '—';
document.getElementById('fips-control').textContent =
f.control_socket || '—';
var errRow = document.getElementById('fips-error-row');
var errEl = document.getElementById('fips-error');
if (f.error && f.error.length) {
errEl.textContent = f.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function renderTorStatus(data) {
var t = data.tor || {};
var state = t.state || 'disabled';
document.getElementById('tor-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('tor-bootstrap').textContent =
(t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%';
document.getElementById('tor-socks').textContent =
t.socks_endpoint || '—';
document.getElementById('tor-ownership').textContent =
t.ownership || '—';
var errRow = document.getElementById('tor-error-row');
var errEl = document.getElementById('tor-error');
if (t.error && t.error.length) {
errEl.textContent = t.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function fetchStatus() {
return sovereignGet('sovereign://fips/status?_=' + Date.now())
.then(function(data) {
renderFipsStatus(data);
renderTorStatus(data);
})
.catch(function(e) {
showMsg('Failed to load status: ' + e.message, true);
});
}
/* ── Peers tab ──────────────────────────────────────────────────── */
function renderPeers(data) {
var body = document.getElementById('peers-body');
var table = document.getElementById('peers-table');
var empty = document.getElementById('peers-empty');
var peers = data.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'No peers (' + data.error + ')'
: 'No peers connected.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var html = '';
peers.forEach(function(p) {
var npub = p.npub || p.id || '';
var addr = p.transport_addr || p.address || p.addr || '';
var transport = p.transport_type || p.transport || '';
var state = p.connectivity || p.state || p.status || '';
var label = p.display_name || '';
html += '<tr>';
html += '<td class="mono">' + esc(npub) +
(label ? '<br><span class="note">' + esc(label) + '</span>' : '') +
'</td>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(transport) + '</td>';
html += '<td class="peer-state-' + esc(state) + '">' + esc(state) + '</td>';
html += '<td><button class="btn btn-del" data-npub="' + esc(npub) +
'" onclick="disconnectPeer(\'' + esc(npub).replace(/'/g, "\\'") +
'\')">✕</button></td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchPeers() {
return sovereignGet('sovereign://fips/peers?_=' + Date.now())
.then(function(data) { renderPeers(data); })
.catch(function(e) {
document.getElementById('peers-empty').textContent =
'Failed to load peers: ' + e.message;
});
}
function connectPeer() {
var npub = document.getElementById('peer-npub').value.trim();
var address = document.getElementById('peer-address').value.trim();
var transport = document.getElementById('peer-transport').value;
if (!npub || !address) {
showMsg('npub and address are required', true);
return;
}
var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) +
'&address=' + encodeURIComponent(address) +
'&transport=' + encodeURIComponent(transport);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Connect failed: ' + d.error, true);
} else {
showMsg('Peer added');
document.getElementById('peer-npub').value = '';
document.getElementById('peer-address').value = '';
fetchPeers();
}
})
.catch(function(e) { showMsg('Connect failed: ' + e.message, true); });
}
function disconnectPeer(npub) {
if (!npub) return;
if (!confirm('Disconnect peer ' + npub + '?')) return;
var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Disconnect failed: ' + d.error, true);
} else {
showMsg('Peer disconnected');
fetchPeers();
}
})
.catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); });
}
function restartService() {
if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return;
showMsg('Restarting FIPS service…');
sovereignGet('sovereign://fips/restart')
.then(function(d) {
if (d.error) showMsg('Restart failed: ' + d.error, true);
else showMsg('Restart initiated');
})
.catch(function(e) { showMsg('Restart failed: ' + e.message, true); });
}
/* ── Network tab ────────────────────────────────────────────────── */
function formatAge(ageMs) {
if (ageMs == null) return '—';
var s = Math.floor(ageMs / 1000);
if (s < 60) return s + 's ago';
var m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
var h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
function ageClass(ageMs) {
if (ageMs == null) return '';
if (ageMs < 10000) return 'last-seen-fresh';
if (ageMs < 60000) return 'last-seen-stale';
return 'last-seen-old';
}
function renderNetwork(data) {
var s = data.summary || {};
var nodes = data.nodes || [];
/* Mesh summary. */
document.getElementById('mesh-known').textContent =
(s.known_nodes != null ? s.known_nodes : '—') +
(s.max_cache_entries != null ? ' / ' + s.max_cache_entries : '');
document.getElementById('mesh-direct').textContent =
(data.peer_count != null ? data.peer_count : '—');
document.getElementById('mesh-depth').textContent =
(s.tree_depth != null ? s.tree_depth : '—');
document.getElementById('mesh-root').textContent =
s.root_npub ? esc(s.root_npub) : '—';
document.getElementById('mesh-parent').textContent =
s.parent_display_name ? esc(s.parent_display_name) :
(s.is_root ? '(this node is root)' : '—');
var errRow = document.getElementById('mesh-error-row');
var errEl = document.getElementById('mesh-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
/* Known nodes table. */
var body = document.getElementById('nodes-body');
var table = document.getElementById('nodes-table');
var empty = document.getElementById('nodes-empty');
var countLabel = document.getElementById('nodes-count-label');
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Network unavailable: ' + data.error
: 'No known nodes (FIPS may not be ready).';
return;
}
/* Sort by last_seen descending (most recent first). */
nodes.sort(function(a, b) {
var aT = a.last_seen_ms || 0;
var bT = b.last_seen_ms || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var name = n.display_name || '—';
var ipv6 = n.ipv6_addr || '';
var age = n.age_ms != null ? n.age_ms : null;
var direct = n.is_direct_peer;
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'discovery only') + '"></span>';
html += '<tr>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td class="mono">' + esc(ipv6) + '</td>';
html += '<td class="' + ageClass(age) + '">' + formatAge(age) + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchNetwork() {
return sovereignGet('sovereign://fips/network?_=' + Date.now())
.then(function(data) { renderNetwork(data); })
.catch(function(e) {
document.getElementById('nodes-empty').textContent =
'Failed to load network: ' + e.message;
});
}
/* ── Tree tab ───────────────────────────────────────────────────── */
function renderTree(data) {
var t = data.tree || {};
var errRow = document.getElementById('tree-error-row');
var errEl = document.getElementById('tree-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
document.getElementById('tree-peers-empty').textContent =
'Tree unavailable: ' + data.error;
document.getElementById('tree-peers-table').style.display = 'none';
/* Clear summary. */
['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr']
.forEach(function(id) { document.getElementById(id).textContent = '—'; });
return;
}
errRow.style.display = 'none';
document.getElementById('tree-root').textContent =
t.root_npub || t.root || '—';
document.getElementById('tree-is-root').textContent =
t.is_root ? 'yes' : 'no';
document.getElementById('tree-depth').textContent =
(t.depth != null ? t.depth : '—');
document.getElementById('tree-parent').textContent =
t.parent_display_name || t.parent || '—';
document.getElementById('tree-node-addr').textContent =
t.my_node_addr || '—';
/* Tree peers (1-hop neighbors). */
var body = document.getElementById('tree-peers-body');
var table = document.getElementById('tree-peers-table');
var empty = document.getElementById('tree-peers-empty');
var peers = t.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = 'No tree peers.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var myAddr = t.my_node_addr || '';
var parentAddr = t.parent || '';
var html = '';
peers.forEach(function(p) {
var addr = p.node_addr || '';
var name = p.display_name || '—';
var depth = p.depth != null ? p.depth : '—';
var dist = p.distance_to_us != null ? p.distance_to_us : '—';
var role = '';
if (addr === parentAddr && !t.is_root) {
role = '<span class="tree-role parent">parent</span>';
} else if (addr === t.root) {
role = '<span class="tree-role root">root</span>';
} else {
role = '<span class="tree-role child">peer</span>';
}
html += '<tr>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td>' + esc(depth) + '</td>';
html += '<td>' + esc(dist) + '</td>';
html += '<td>' + role + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchTree() {
return sovereignGet('sovereign://fips/tree?_=' + Date.now())
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('tree-peers-empty').textContent =
'Failed to load tree: ' + e.message;
});
}
/* ── Directory tab ───────────────────────────────────────────────── */
function renderDirectory(data) {
var body = document.getElementById('dir-body');
var table = document.getElementById('dir-table');
var empty = document.getElementById('dir-empty');
var countLabel = document.getElementById('dir-count-label');
var actions = document.getElementById('dir-actions');
var nodes = data.nodes || [];
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
actions.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Directory unavailable: ' + data.error
: 'No advertised nodes found on the advert relays.';
return;
}
/* Sort: our own node first (pinned), then by created_at descending. */
nodes.sort(function(a, b) {
var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0;
var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0;
if (aOurs !== bOurs) return bOurs - aOurs;
var aT = a.created_at || 0;
var bT = b.created_at || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
actions.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var direct = n.is_direct_peer;
var isOurs = (npub && npub === gOurNpub);
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'not connected') + '"></span>';
/* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */
var endpointsStr = '—';
if (n.endpoints && n.endpoints.length) {
var ep = n.endpoints[0];
endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || '');
if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1);
}
/* Connect: clickable text, not a button. */
var firstEp = (n.endpoints && n.endpoints[0]) || {};
var addr = firstEp.addr || '';
var transport = firstEp.transport || 'udp';
var connectLink = '';
if (isOurs) {
connectLink = '<span class="dir-ours-tag">this is you</span>';
} else if (addr && !direct) {
connectLink = '<a class="dir-connect-link" href="javascript:void(0)" onclick="connectFromDirectory(\'' +
esc(npub).replace(/'/g, "\\'") + '\', \'' +
esc(addr).replace(/'/g, "\\'") + '\', \'' +
esc(transport) + '\')">connect</a>';
} else if (direct) {
connectLink = '<span class="note">connected</span>';
}
var rowClass = isOurs ? ' class="dir-ours-row"' : '';
html += '<tr' + rowClass + '>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td class="mono">' + endpointsStr + '</td>';
html += '<td>' + connectLink + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchDirectory() {
var empty = document.getElementById('dir-empty');
empty.style.display = '';
empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…';
document.getElementById('dir-table').style.display = 'none';
document.getElementById('dir-actions').style.display = 'none';
return sovereignGet('sovereign://fips/directory?_=' + Date.now())
.then(function(data) { renderDirectory(data); })
.catch(function(e) {
empty.textContent = 'Failed to load directory: ' + e.message;
});
}
function connectFromDirectory(npub, address, transport) {
/* Switch to the Peers tab, pre-fill the add-peer form, and connect. */
switchTab('peers');
document.getElementById('peer-npub').value = npub;
document.getElementById('peer-address').value = address;
var sel = document.getElementById('peer-transport');
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === transport) {
sel.selectedIndex = i;
break;
}
}
connectPeer();
}
/* ── Refresh + auto-refresh ─────────────────────────────────────── */
function refresh() {
showMsg('');
var activeTab = document.querySelector('.tab-btn.active');
var tabName = activeTab ? activeTab.dataset.tab : 'status';
var promises = [fetchStatus(), fetchPeers()];
if (tabName === 'network') promises.push(fetchNetwork());
if (tabName === 'tree') promises.push(fetchTree());
Promise.all(promises).then(function() {});
}
document.getElementById('refresh-btn').addEventListener('click', refresh);
document.getElementById('connect-btn').addEventListener('click', connectPeer);
document.getElementById('restart-btn').addEventListener('click', restartService);
var dirRefreshBtn = document.getElementById('dir-refresh-btn');
if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory);
/* Initial load. */
refresh();
/* Auto-refresh status + peers every 5s. Network/tree refresh on
* tab activation and every 10s when active. */
setInterval(function() {
fetchStatus();
fetchPeers();
}, 5000);
setInterval(function() {
var activeTab = document.querySelector('.tab-btn.active');
if (!activeTab) return;
var tabName = activeTab.dataset.tab;
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
}, 10000);
+232
View File
@@ -0,0 +1,232 @@
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeItems(items) {
return Array.isArray(items) ? items.filter(Boolean) : [];
}
function isSubmenuItem(item) {
return item && typeof item === 'object' && Array.isArray(item.submenu);
}
function positionSubmenuPanel(detailsEl) {
if (!detailsEl) return;
const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label');
const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel');
if (!summaryEl || !panelEl) return;
const gap = 6;
const pad = 8;
const summaryRect = summaryEl.getBoundingClientRect();
const panelRect = panelEl.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const panelWidth = panelRect.width || panelEl.offsetWidth || 180;
const panelHeight = panelRect.height || panelEl.offsetHeight || 40;
let left = summaryRect.right + gap;
if (left + panelWidth > viewportWidth - pad) {
left = summaryRect.left - panelWidth - gap;
}
left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad));
let top = summaryRect.top;
if (top + panelHeight > viewportHeight - pad) {
top = viewportHeight - panelHeight - pad;
}
top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad));
panelEl.style.left = `${Math.round(left)}px`;
panelEl.style.top = `${Math.round(top)}px`;
}
function closeSiblingSubmenus(parentEl, exceptEl = null) {
if (!parentEl) return;
const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]');
siblings.forEach((detailsEl) => {
if (exceptEl && detailsEl === exceptEl) return;
detailsEl.open = false;
});
}
function renderItems(containerEl, items, closeMenu) {
containerEl.innerHTML = '';
const normalized = normalizeItems(items);
normalized.forEach((item) => {
if (!item || typeof item !== 'object') return;
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dot-menu-divider';
divider.setAttribute('role', 'separator');
containerEl.appendChild(divider);
return;
}
if (isSubmenuItem(item)) {
const detailsEl = document.createElement('details');
detailsEl.className = 'dot-menu-submenu';
const summaryEl = document.createElement('summary');
summaryEl.className = 'dot-menu-submenu-label';
summaryEl.textContent = String(item.label || 'More');
const submenuPanel = document.createElement('div');
submenuPanel.className = 'dot-menu-submenu-panel';
renderItems(submenuPanel, item.submenu, closeMenu);
detailsEl.appendChild(summaryEl);
detailsEl.appendChild(submenuPanel);
containerEl.appendChild(detailsEl);
detailsEl.addEventListener('toggle', () => {
if (!detailsEl.open) return;
closeSiblingSubmenus(containerEl, detailsEl);
requestAnimationFrame(() => positionSubmenuPanel(detailsEl));
});
summaryEl.addEventListener('click', (event) => {
event.stopPropagation();
});
submenuPanel.addEventListener('click', (event) => {
event.stopPropagation();
});
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'dot-menu-item';
button.textContent = String(item.label || 'Untitled');
button.addEventListener('click', (event) => {
event.stopPropagation();
try {
if (typeof item.onClick === 'function') {
item.onClick();
}
} finally {
closeMenu();
}
});
containerEl.appendChild(button);
});
}
window.mountDotMenu = function mountDotMenu(hostEl, config = {}) {
if (!(hostEl instanceof Element)) {
throw new Error('mountDotMenu(hostEl, config) requires a valid host element.');
}
let currentItems = normalizeItems(config.items);
const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯';
const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim()
? config.ariaLabel.trim()
: 'Menu options';
const position = config.position === 'left' ? 'left' : 'right';
const wrap = document.createElement('div');
wrap.className = 'dot-menu-wrap';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dot-menu-trigger';
trigger.setAttribute('aria-label', ariaLabel);
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = triggerLabel;
const panel = document.createElement('div');
panel.className = 'dot-menu-panel';
panel.dataset.position = position;
panel.setAttribute('role', 'menu');
wrap.appendChild(trigger);
wrap.appendChild(panel);
hostEl.innerHTML = '';
hostEl.appendChild(wrap);
const close = () => {
panel.classList.remove('is-open');
wrap.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
detailsEl.open = false;
});
};
const open = () => {
panel.classList.add('is-open');
wrap.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
};
const toggle = () => {
if (panel.classList.contains('is-open')) {
close();
} else {
open();
}
};
const onTriggerClick = (event) => {
event.stopPropagation();
toggle();
};
const onDocumentClick = (event) => {
if (!wrap.contains(event.target)) {
close();
}
};
const onDocumentKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
const onViewportChange = () => {
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
positionSubmenuPanel(detailsEl);
});
};
trigger.addEventListener('click', onTriggerClick);
panel.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
renderItems(panel, currentItems, close);
const updateItems = (newItems) => {
currentItems = normalizeItems(newItems);
renderItems(panel, currentItems, close);
};
const destroy = () => {
close();
trigger.removeEventListener('click', onTriggerClick);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
if (wrap.parentNode) {
wrap.parentNode.removeChild(wrap);
}
};
return { open, close, destroy, updateItems };
}
+232
View File
@@ -0,0 +1,232 @@
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeItems(items) {
return Array.isArray(items) ? items.filter(Boolean) : [];
}
function isSubmenuItem(item) {
return item && typeof item === 'object' && Array.isArray(item.submenu);
}
function positionSubmenuPanel(detailsEl) {
if (!detailsEl) return;
const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label');
const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel');
if (!summaryEl || !panelEl) return;
const gap = 6;
const pad = 8;
const summaryRect = summaryEl.getBoundingClientRect();
const panelRect = panelEl.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const panelWidth = panelRect.width || panelEl.offsetWidth || 180;
const panelHeight = panelRect.height || panelEl.offsetHeight || 40;
let left = summaryRect.right + gap;
if (left + panelWidth > viewportWidth - pad) {
left = summaryRect.left - panelWidth - gap;
}
left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad));
let top = summaryRect.top;
if (top + panelHeight > viewportHeight - pad) {
top = viewportHeight - panelHeight - pad;
}
top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad));
panelEl.style.left = `${Math.round(left)}px`;
panelEl.style.top = `${Math.round(top)}px`;
}
function closeSiblingSubmenus(parentEl, exceptEl = null) {
if (!parentEl) return;
const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]');
siblings.forEach((detailsEl) => {
if (exceptEl && detailsEl === exceptEl) return;
detailsEl.open = false;
});
}
function renderItems(containerEl, items, closeMenu) {
containerEl.innerHTML = '';
const normalized = normalizeItems(items);
normalized.forEach((item) => {
if (!item || typeof item !== 'object') return;
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'dot-menu-divider';
divider.setAttribute('role', 'separator');
containerEl.appendChild(divider);
return;
}
if (isSubmenuItem(item)) {
const detailsEl = document.createElement('details');
detailsEl.className = 'dot-menu-submenu';
const summaryEl = document.createElement('summary');
summaryEl.className = 'dot-menu-submenu-label';
summaryEl.textContent = String(item.label || 'More');
const submenuPanel = document.createElement('div');
submenuPanel.className = 'dot-menu-submenu-panel';
renderItems(submenuPanel, item.submenu, closeMenu);
detailsEl.appendChild(summaryEl);
detailsEl.appendChild(submenuPanel);
containerEl.appendChild(detailsEl);
detailsEl.addEventListener('toggle', () => {
if (!detailsEl.open) return;
closeSiblingSubmenus(containerEl, detailsEl);
requestAnimationFrame(() => positionSubmenuPanel(detailsEl));
});
summaryEl.addEventListener('click', (event) => {
event.stopPropagation();
});
submenuPanel.addEventListener('click', (event) => {
event.stopPropagation();
});
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'dot-menu-item';
button.textContent = String(item.label || 'Untitled');
button.addEventListener('click', (event) => {
event.stopPropagation();
try {
if (typeof item.onClick === 'function') {
item.onClick();
}
} finally {
closeMenu();
}
});
containerEl.appendChild(button);
});
}
export function mountDotMenu(hostEl, config = {}) {
if (!(hostEl instanceof Element)) {
throw new Error('mountDotMenu(hostEl, config) requires a valid host element.');
}
let currentItems = normalizeItems(config.items);
const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯';
const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim()
? config.ariaLabel.trim()
: 'Menu options';
const position = config.position === 'left' ? 'left' : 'right';
const wrap = document.createElement('div');
wrap.className = 'dot-menu-wrap';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dot-menu-trigger';
trigger.setAttribute('aria-label', ariaLabel);
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = triggerLabel;
const panel = document.createElement('div');
panel.className = 'dot-menu-panel';
panel.dataset.position = position;
panel.setAttribute('role', 'menu');
wrap.appendChild(trigger);
wrap.appendChild(panel);
hostEl.innerHTML = '';
hostEl.appendChild(wrap);
const close = () => {
panel.classList.remove('is-open');
wrap.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
detailsEl.open = false;
});
};
const open = () => {
panel.classList.add('is-open');
wrap.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
};
const toggle = () => {
if (panel.classList.contains('is-open')) {
close();
} else {
open();
}
};
const onTriggerClick = (event) => {
event.stopPropagation();
toggle();
};
const onDocumentClick = (event) => {
if (!wrap.contains(event.target)) {
close();
}
};
const onDocumentKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
const onViewportChange = () => {
panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => {
positionSubmenuPanel(detailsEl);
});
};
trigger.addEventListener('click', onTriggerClick);
panel.addEventListener('click', (event) => event.stopPropagation());
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
renderItems(panel, currentItems, close);
const updateItems = (newItems) => {
currentItems = normalizeItems(newItems);
renderItems(panel, currentItems, close);
};
const destroy = () => {
close();
trigger.removeEventListener('click', onTriggerClick);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
if (wrap.parentNode) {
wrap.parentNode.removeChild(wrap);
}
};
return { open, close, destroy, updateItems };
}
+369
View File
@@ -0,0 +1,369 @@
/*
* perf-probe.js — per-tab performance probe for sovereign_browser
*
* Injected by WebKitUserContentManager into every non-sovereign:// page
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
* - resource entries (PerformanceObserver 'resource') -> net counts
* - requestAnimationFrame cadence -> fps
* - patched setTimeout/setInterval -> active timer count + top intervals
* - patched addEventListener -> event listener count
* - patched Worker -> worker count
* - performance.memory -> heap used
* - document.getElementsByTagName('*').length -> DOM node count
*
* Batches a report every 1s and POSTs it to:
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
*
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
* tag injected before this script runs (see inject_perf_probe() in
* nostr_bridge.c / main.c). If absent, the report is dropped.
*
* The probe is a no-op on sovereign:// pages (the injector skips them)
* to avoid self-noise and recursion.
*/
(function () {
'use strict';
// Honor the skip flag set by the preamble on sovereign:// / about:
// pages. The skip flag is re-evaluated on every navigation (the
// preamble runs at document_start for each new document), so we also
// reset our installed flag here to allow re-injection after a
// navigation from an internal page to a normal one.
if (window.__sbPerfProbeSkip) return;
if (window.__sbPerfProbe) return; // already running in this document
window.__sbPerfProbe = true;
// Tab index is injected by the browser via window.__sbPerfTabIndex
// (set by the preamble that runs before this script). Read it once.
var TAB_INDEX = window.__sbPerfTabIndex;
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
// Capture the native timers early — the heartbeat (below) needs
// origSetTimeout before the timer-patching block runs.
var origSetTimeout = window.setTimeout;
var origSetInterval = window.setInterval;
var origClearTimeout = window.clearTimeout;
var origClearInterval = window.clearInterval;
// ── Long tasks ──────────────────────────────────────────────────
var longTasks = []; // last 30s of {t, duration, name}
var longTasksLastSec = []; // tasks in the current 1s window
var longTaskSources = {}; // name -> {total_ms, count}
var longTaskSupported = false;
if (typeof PerformanceObserver !== 'undefined') {
try {
var ltObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
var rec = {
t: performance.now(),
duration: e.duration,
name: e.name || 'unknown',
attribution: (e.attribution && e.attribution[0])
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
: ''
};
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = rec.name || 'unknown';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += rec.duration;
longTaskSources[key].count++;
});
});
ltObs.observe({ entryTypes: ['longtask'] });
longTaskSupported = true;
} catch (e) { /* longtask not supported */ }
}
// ── Main-thread blockage fallback ───────────────────────────────
// WebKitGTK does not fire 'longtask' entries, so we measure main-
// thread blockage directly: schedule a 0ms heartbeat and measure
// how late it fires. If the main thread is blocked by a busy loop,
// the heartbeat is delayed. The total delay in a 1s window is a
// proxy for cpu_busy_percent.
var heartbeatDelayMs = 0; // accumulated delay in the current window
var heartbeatLast = performance.now();
function heartbeat() {
var now = performance.now();
var expected = heartbeatLast + 50; // we schedule every 50ms
var delay = now - expected;
if (delay > 5) { // ignore small scheduler jitter
heartbeatDelayMs += delay;
// Record as a synthetic long task if > 50ms.
if (delay > 50) {
var rec = { t: now, duration: delay, name: 'main-thread-block',
attribution: '' };
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = 'main-thread-block';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += delay;
longTaskSources[key].count++;
}
}
heartbeatLast = now;
origSetTimeout.call(window, heartbeat, 50);
}
// Always start the heartbeat. On engines where longtask fires
// reliably, the heartbeat delay will be near-zero (long tasks are
// captured by the observer). On WebKitGTK, where longtask is
// accepted but never fires, the heartbeat is the sole source of
// cpu_busy measurement. The report() function takes the max of
// both, so there's no double-counting.
origSetTimeout.call(window, heartbeat, 50);
// ── Resource entries (network) ────────────────────────────────
var netRequestsLastSec = [];
var netEndpoints = {}; // url-prefix -> count
if (typeof PerformanceObserver !== 'undefined') {
try {
var resObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
var key = e.name.split('?')[0];
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
});
});
resObs.observe({ entryTypes: ['resource'] });
} catch (e) { /* resource not supported */ }
}
// ── FPS via requestAnimationFrame ───────────────────────────────
var rafFrames = 0;
var fps = 0;
function rafLoop(ts) {
rafFrames++;
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
var activeTimers = {}; // id -> {code, interval_ms, count}
var timerTop = {}; // "code|interval" -> count
var nextTimerId = 1;
function labelFor(fn) {
try {
var s = (fn && fn.name) ? fn.name : 'anonymous';
// Truncate long inline-function bodies.
if (s.length > 40) s = s.slice(0, 40);
return s;
} catch (e) { return 'anonymous'; }
}
// origSetTimeout / origSetInterval / origClearTimeout /
// origClearInterval were captured at the top of the IIFE (before the
// heartbeat was installed), so they reference the native timers.
window.setTimeout = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
var key = label + '|0';
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
delete activeTimers[id];
return fn.apply(this, args);
};
return origSetTimeout.call(this, wrapped, delay);
};
window.setInterval = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
var interval = delay | 0;
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
var key = label + '|' + interval;
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
if (activeTimers[id]) activeTimers[id].count++;
return fn.apply(this, args);
};
return origSetInterval.call(this, wrapped, delay);
};
window.clearTimeout = function (id) {
delete activeTimers[id];
return origClearTimeout.call(this, id);
};
window.clearInterval = function (id) {
delete activeTimers[id];
return origClearInterval.call(this, id);
};
// ── Event listener count (patched addEventListener) ─────────────
var eventListenerCount = 0;
var origAEL = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
// Skip our own internal listeners (tagged with __sbInternal).
if (!fn || !fn.__sbInternal) eventListenerCount++;
return origAEL.call(this, type, fn, opts);
};
// ── Worker count (patched Worker constructor) ───────────────────
var workerCount = 0;
var OrigWorker = window.Worker;
if (OrigWorker) {
window.Worker = function (url, opts) {
workerCount++;
return new OrigWorker(url, opts);
};
window.Worker.prototype = OrigWorker.prototype;
}
// ── In-flight network (patched fetch + XHR.open) ────────────────
var netInFlight = 0;
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function () {
netInFlight++;
var p = origFetch.apply(this, arguments);
var dec = function () { if (netInFlight > 0) netInFlight--; };
p.then(dec, dec);
return p;
};
}
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
netInFlight++;
var onEnd = function () {
if (netInFlight > 0) netInFlight--;
};
onEnd.__sbInternal = true;
this.addEventListener('loadend', onEnd);
return origXhrOpen.apply(this, arguments);
};
// ── Reporting ───────────────────────────────────────────────────
function report() {
// Trim long-task timeline to last 30s.
var now = performance.now();
while (longTasks.length && (now - longTasks[0].t) > 30000) {
longTasks.shift();
}
// cpu_busy_percent: sum of long-task durations in the last 1s
// window. If the longtask PerformanceObserver isn't supported
// (WebKitGTK), fall back to the heartbeat-delay measurement.
var busyMs = 0;
for (var i = 0; i < longTasksLastSec.length; i++) {
busyMs += longTasksLastSec[i].duration;
}
// If the heartbeat measured more blockage than the longtask
// observer (e.g. on WebKitGTK where longtask never fires), use
// the heartbeat measurement.
if (heartbeatDelayMs > busyMs) {
busyMs = heartbeatDelayMs;
}
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
if (cpuBusy > 100) cpuBusy = 100;
heartbeatDelayMs = 0; // reset for next window
// Top timer sources.
var timerTopArr = [];
Object.keys(timerTop).forEach(function (k) {
var parts = k.split('|');
timerTopArr.push({
code: parts[0],
interval_ms: parseInt(parts[1], 10) || 0,
count: timerTop[k]
});
});
timerTopArr.sort(function (a, b) { return b.count - a.count; });
timerTopArr = timerTopArr.slice(0, 5);
// Top network endpoints (last window).
var netTopArr = [];
Object.keys(netEndpoints).forEach(function (u) {
netTopArr.push({ url: u, count: netEndpoints[u] });
});
netTopArr.sort(function (a, b) { return b.count - a.count; });
netTopArr = netTopArr.slice(0, 5);
// Top long-task sources.
var ltSourcesArr = [];
Object.keys(longTaskSources).forEach(function (k) {
ltSourcesArr.push({
name: k,
total_ms: Math.round(longTaskSources[k].total_ms),
count: longTaskSources[k].count
});
});
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
ltSourcesArr = ltSourcesArr.slice(0, 8);
// Heap.
var heapMb = -1;
if (performance.memory && performance.memory.usedJSHeapSize) {
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
}
// DOM node count.
var domNodes = -1;
try {
domNodes = document.getElementsByTagName('*').length;
} catch (e) { /* document not ready */ }
var payload = {
tab_index: TAB_INDEX,
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
fps: fps,
timer_count: Object.keys(activeTimers).length,
timer_top: timerTopArr,
net_in_flight: netInFlight,
net_requests_last_sec: netRequestsLastSec.length,
net_top_endpoints: netTopArr,
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
event_listener_count: eventListenerCount,
worker_count: workerCount,
dom_node_count: domNodes,
long_tasks_last_sec: longTasksLastSec.map(function (r) {
return {
duration: Math.round(r.duration),
name: r.name,
attribution: r.attribution
};
}),
long_task_top_sources: ltSourcesArr,
long_task_timeline: longTasks.map(function (r) {
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
})
};
// Reset per-window accumulators.
longTasksLastSec = [];
netRequestsLastSec = [];
netEndpoints = {};
timerTop = {};
// Send. Use sync XHR to sovereign:// (same convention as the
// window.nostr shim — WebKit's custom scheme handler doesn't
// reliably support fetch() or async XHR for sovereign:// URLs).
try {
var body = encodeURIComponent(JSON.stringify(payload));
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
'&body=' + body;
var x = new XMLHttpRequest();
x.open('GET', url, false); // synchronous
x.send();
} catch (e) { /* swallow — probe must never break the page */ }
}
// FPS counter: count frames in each 1s window.
setInterval(function () {
fps = rafFrames;
rafFrames = 0;
}, 1000);
// Report every 1s. Use the original setInterval so we don't track
// ourselves in activeTimers.
origSetInterval.call(window, report, 1000);
})();
+900
View File
@@ -0,0 +1,900 @@
/* Inlined stubs for sovereign_browser — WebKit's custom sovereign://
* scheme does not resolve relative ES module imports, so we inline the
* dependencies instead of importing from separate .mjs files. These
* stubs are sufficient for the chat composer (no uploads, no preview). */
function uploadToAllServers() {
return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser'));
}
function getBlobUrl(sha256, ext) {
return `blossom://${sha256}.${ext}`;
}
function htmlFormatText(text = '') {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function hydrateNostrEntities() {
/* No-op in sovereign_browser. */
}
function debounce(fn, delay = 250) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function getFileExtension(file) {
const fromName = String(file?.name || '').split('.').pop();
if (fromName && fromName !== file?.name) return fromName.toLowerCase();
const fromType = String(file?.type || '').split('/').pop();
return fromType ? fromType.toLowerCase() : '';
}
function getCaretCharacterOffsetWithin(element) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
function setCaretByCharacterOffset(element, offset) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let currentOffset = 0;
let found = false;
const walk = (node) => {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length || 0;
if (currentOffset + textLength >= offset) {
range.setStart(node, Math.max(0, offset - currentOffset));
range.collapse(true);
found = true;
} else {
currentOffset += textLength;
}
return;
}
for (const child of node.childNodes) {
walk(child);
if (found) return;
}
};
walk(element);
if (!found) {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
function detectMentionQuery(text, caretOffset) {
const left = text.slice(0, caretOffset);
const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i);
if (!match) return null;
const query = match[2] || '';
const start = left.length - query.length - 1;
const end = caretOffset;
return { query, start, end };
}
function escapeHtml(text = '') {
return String(text)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/'/g, '\u0026#39;');
}
function toNpub(pubkeyOrNpub) {
if (!pubkeyOrNpub) return null;
if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub);
try {
return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null;
} catch {
return null;
}
}
function positionDropdownNearCaret(hostEl, dropdownEl) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
let rect = range.getBoundingClientRect();
if (!rect || (!rect.width && !rect.height)) {
const hostRect = hostEl.getBoundingClientRect();
rect = {
left: hostRect.left + 8,
top: hostRect.top + hostRect.height - 8,
bottom: hostRect.top + hostRect.height - 8
};
}
dropdownEl.style.left = `${Math.max(8, rect.left)}px`;
dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`;
}
function filterMentionCandidates(followedProfiles, query) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
return followedProfiles
.filter((p) => {
const name = String(p?.name || '').toLowerCase();
const display = String(p?.display_name || '').toLowerCase();
const nip05 = String(p?.nip05 || '').toLowerCase();
const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase();
return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q);
})
.slice(0, 8);
}
function createUploadIcon(kind = 'upload') {
const normalizedKind = kind === 'image' ? 'image' : 'upload';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'post-composer-upload-icon';
if (normalizedKind === 'image') {
btn.classList.add('is-image-icon');
}
if (normalizedKind === 'image') {
btn.title = 'Attach image';
btn.setAttribute('aria-label', 'Attach image');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2"/>
<circle cx="9" cy="10" r="1.5"/>
<path d="M6 16l4-4 3 3 3-3 2 2"/>
</svg>
`;
return btn;
}
btn.title = 'Upload file';
btn.setAttribute('aria-label', 'Upload file');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<path d="M7 10l5-5 5 5"/>
<path d="M12 5v12"/>
</svg>
`;
return btn;
}
window.mountComposer = function mountComposer(hostEl, options = {}) {
if (!hostEl) throw new Error('mountComposer requires a host element');
let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : [];
const showUploadIcon = options.showUploadIcon !== false;
const showPreview = options.showPreview !== false;
const autoHideOnSubmit = options.autoHideOnSubmit === true;
const submitOnEnter = options.submitOnEnter === true;
const hideOnEscape = options.hideOnEscape === true;
const alwaysShowSendButton = options.alwaysShowSendButton === true;
const clearBeforeSubmit = options.clearBeforeSubmit !== false;
const enableHistory = options.enableHistory !== false;
const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim()
? options.historyKey.trim()
: 'post_composer_history_v1';
const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50)));
const layout = options.layout === 'inline' ? 'inline' : 'default';
const popupMode = options.popupMode === true;
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null;
const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url';
const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload');
const defaultFileAccept = uploadMode === 'direct-image'
? 'image/*'
: 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip';
const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim()
? options.fileAccept.trim()
: defaultFileAccept;
hostEl.setAttribute('contenteditable', 'true');
let wrapper = hostEl.parentElement;
if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'post-composer-wrapper';
hostEl.parentNode?.insertBefore(wrapper, hostEl);
wrapper.appendChild(hostEl);
}
const editorShell = document.createElement('div');
editorShell.className = 'post-composer-editor-shell';
wrapper.insertBefore(editorShell, hostEl);
editorShell.appendChild(hostEl);
const dragOverlay = document.createElement('div');
dragOverlay.className = 'post-composer-drag-overlay';
dragOverlay.textContent = 'Drop files to upload';
editorShell.appendChild(dragOverlay);
const uploadStatus = document.createElement('div');
uploadStatus.className = 'post-composer-uploading';
uploadStatus.style.display = 'none';
editorShell.appendChild(uploadStatus);
let uploadButton = null;
if (showUploadIcon) {
uploadButton = createUploadIcon(uploadIcon);
editorShell.appendChild(uploadButton);
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = fileAccept;
fileInput.style.display = 'none';
wrapper.appendChild(fileInput);
const previewEl = document.createElement('div');
previewEl.className = 'post-composer-preview';
previewEl.innerHTML = `
<div class="post-composer-preview-label">Preview</div>
<div class="post-composer-preview-content divPostContent"></div>
`;
const previewContentEl = previewEl.querySelector('.post-composer-preview-content');
const sendButton = document.createElement('button');
sendButton.type = 'button';
sendButton.className = 'post-composer-send-btn';
if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible');
sendButton.textContent = 'Send';
sendButton.disabled = true;
/* Stop-mode state: when active, the send button transforms into a
* stop button (different label, click handler, and styling). This is
* used by the agent chat to let the user cancel an in-progress agent
* run without needing a separate stop button. */
let stopModeActive = false;
let stopModeHandler = null;
const sendLabel = 'Send';
const stopLabel = 'Stop';
if (layout === 'inline') {
wrapper.classList.add('post-composer-wrapper--inline');
if (showPreview) wrapper.appendChild(previewEl);
const inlineRow = document.createElement('div');
inlineRow.className = 'post-composer-inline-row';
wrapper.appendChild(inlineRow);
inlineRow.appendChild(editorShell);
inlineRow.appendChild(sendButton);
} else if (popupMode) {
wrapper.classList.add('post-composer-wrapper--popup');
const scrollBody = document.createElement('div');
scrollBody.className = 'post-composer-popup-body';
scrollBody.appendChild(editorShell);
if (showPreview) scrollBody.appendChild(previewEl);
wrapper.appendChild(scrollBody);
const stickyFooter = document.createElement('div');
stickyFooter.className = 'post-composer-popup-footer';
stickyFooter.appendChild(sendButton);
wrapper.appendChild(stickyFooter);
} else {
if (showPreview) wrapper.appendChild(previewEl);
wrapper.appendChild(sendButton);
}
const mentionDropdown = document.createElement('div');
mentionDropdown.className = 'post-composer-mention-dropdown';
document.body.appendChild(mentionDropdown);
let dragDepth = 0;
let uploadingCount = 0;
let externallyDisabled = options.disabled === true;
let pendingAttachments = [];
let mentionState = {
query: '',
start: -1,
end: -1,
candidates: [],
activeIndex: 0,
visible: false
};
function loadHistory() {
if (!enableHistory) return [];
try {
const raw = localStorage.getItem(historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function saveHistory(nextHistory) {
if (!enableHistory) return;
try {
localStorage.setItem(historyKey, JSON.stringify(nextHistory));
} catch {
// Ignore localStorage failures.
}
}
let history = loadHistory();
let historyIndex = -1;
let historyDraft = '';
function pushToHistory(text) {
if (!enableHistory) return;
const normalized = String(text || '').trim();
if (!normalized) return;
history = history.filter((item) => item !== normalized);
history.unshift(normalized);
if (history.length > historyMaxEntries) {
history = history.slice(0, historyMaxEntries);
}
saveHistory(history);
historyIndex = -1;
historyDraft = '';
}
function resetHistoryBrowseState() {
historyIndex = -1;
historyDraft = '';
}
function setEditorText(text = '') {
hostEl.innerText = String(text || '');
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
renderPreview();
updateSendButtonVisibility();
}
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
return;
}
if (pendingAttachments.length > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`;
return;
}
uploadStatus.style.display = 'none';
uploadStatus.textContent = '';
}
function hideMentionDropdown() {
mentionState.visible = false;
mentionState.candidates = [];
mentionState.activeIndex = 0;
mentionDropdown.classList.remove('is-visible');
mentionDropdown.innerHTML = '';
}
function renderMentionDropdown() {
if (!mentionState.visible || mentionState.candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionDropdown.innerHTML = mentionState.candidates
.map((profile, idx) => {
const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}…`;
const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}…`;
const avatar = profile.picture
? `<img class="post-composer-mention-avatar" src="${escapeHtml(profile.picture)}" alt="" onerror="this.style.visibility='hidden'" />`
: `<div class="post-composer-mention-avatar" style="background:var(--muted-color);"></div>`;
return `
<div class="post-composer-mention-item ${idx === mentionState.activeIndex ? 'is-active' : ''}" data-index="${idx}">
${avatar}
<div class="post-composer-mention-meta">
<div class="post-composer-mention-name">${escapeHtml(display)}</div>
<div class="post-composer-mention-handle">${escapeHtml(handle)}</div>
</div>
</div>
`;
})
.join('');
mentionDropdown.classList.add('is-visible');
positionDropdownNearCaret(hostEl, mentionDropdown);
}
function replaceMentionWithProfile(profile) {
const npub = toNpub(profile?.npub || profile?.pubkey);
if (!npub) return;
const currentText = hostEl.innerText || '';
if (mentionState.start < 0 || mentionState.end < mentionState.start) return;
const replacement = `nostr:${npub}`;
const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end);
hostEl.innerText = nextText;
const nextCaret = mentionState.start + replacement.length;
setCaretByCharacterOffset(hostEl, nextCaret);
hideMentionDropdown();
queuePreviewRender();
updateSendButtonVisibility();
}
function updateMentionStateFromCaret() {
const text = hostEl.innerText || '';
const caret = getCaretCharacterOffsetWithin(hostEl);
const mention = detectMentionQuery(text, caret);
if (!mention) {
hideMentionDropdown();
return;
}
const candidates = filterMentionCandidates(followedProfiles, mention.query);
if (candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionState = {
...mention,
candidates,
activeIndex: 0,
visible: true
};
renderMentionDropdown();
}
function normalizePendingAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const dataUrl = String(att?.dataUrl || '').trim();
if (!dataUrl.startsWith('data:image/')) return null;
return {
id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`),
name: String(att?.name || 'image').trim() || 'image',
mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined),
dataUrl
};
})
.filter(Boolean);
}
async function insertUploadedFiles(files) {
const arr = Array.from(files || []).filter((f) => f && f.size > 0);
if (arr.length === 0) return;
uploadingCount += arr.length;
updateUploadingUi();
try {
if (uploadMode === 'direct-image') {
if (!onFileAttach) {
console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.');
return;
}
const attached = await onFileAttach(arr);
const normalized = normalizePendingAttachments(attached);
if (normalized.length > 0) {
pendingAttachments = [...pendingAttachments, ...normalized];
}
updateSendButtonVisibility();
return;
}
for (const file of arr) {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);
updateUploadingUi();
queuePreviewRender();
}
}
function updateSendButtonVisibility() {
const hasContent = !!(hostEl.innerText || '').trim();
const hasAttachments = pendingAttachments.length > 0;
const canSubmit = hasContent || hasAttachments;
const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled;
sendButton.classList.toggle('is-visible', canSubmit);
if (alwaysShowSendButton) sendButton.classList.add('is-visible');
sendButton.disabled = !interactive;
sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1;
}
function clearComposerContent() {
hostEl.innerHTML = '';
hostEl.innerText = '';
pendingAttachments = [];
renderPreview();
updateUploadingUi();
updateSendButtonVisibility();
hideMentionDropdown();
resetHistoryBrowseState();
}
function hideComposer() {
wrapper.style.display = 'none';
}
function showComposer() {
wrapper.style.display = '';
}
function renderPreview() {
if (!showPreview || !previewContentEl) return;
const raw = hostEl.innerText || '';
if (!raw.trim()) {
previewEl.classList.remove('is-visible');
previewContentEl.innerHTML = '';
return;
}
previewEl.classList.add('is-visible');
previewContentEl.innerHTML = htmlFormatText(raw);
hydrateNostrEntities(previewContentEl);
}
const queuePreviewRender = debounce(renderPreview, 250);
function onInput() {
queuePreviewRender();
updateMentionStateFromCaret();
updateSendButtonVisibility();
if (historyIndex !== -1) {
resetHistoryBrowseState();
}
}
function onKeydown(event) {
if (event.key === 'Tab' && !event.shiftKey) {
const hasContent = !!(hostEl.innerText || '').trim();
if (hasContent && !mentionState.visible) {
event.preventDefault();
sendButton.focus();
return;
}
}
const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter';
if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) {
event.preventDefault();
event.stopPropagation();
onSendClick();
return;
}
if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) {
event.preventDefault();
onSendClick();
return;
}
if (event.key === 'Escape') {
if (mentionState.visible) {
event.preventDefault();
hideMentionDropdown();
return;
}
if (hideOnEscape) {
event.preventDefault();
hideComposer();
return;
}
}
if (!mentionState.visible && enableHistory) {
const currentText = hostEl.innerText || '';
const caretOffset = getCaretCharacterOffsetWithin(hostEl);
const singleLine = !currentText.includes('\n');
if (event.key === 'ArrowUp') {
const atStart = caretOffset <= 0;
if (atStart || singleLine) {
event.preventDefault();
if (historyIndex === -1) {
historyDraft = currentText;
}
if (historyIndex < history.length - 1) {
historyIndex += 1;
setEditorText(history[historyIndex] || '');
}
return;
}
}
if (event.key === 'ArrowDown') {
const atEnd = caretOffset >= currentText.length;
if ((atEnd || singleLine) && historyIndex >= 0) {
event.preventDefault();
historyIndex -= 1;
if (historyIndex < 0) {
setEditorText(historyDraft || '');
} else {
setEditorText(history[historyIndex] || '');
}
return;
}
}
}
if (!mentionState.visible) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const selected = mentionState.candidates[mentionState.activeIndex];
if (selected) replaceMentionWithProfile(selected);
return;
}
}
function onSelectionChange() {
if (!mentionState.visible) return;
updateMentionStateFromCaret();
}
function onDocumentClick(event) {
if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return;
hideMentionDropdown();
}
function onDragEnter(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth += 1;
editorShell.classList.add('is-dragover');
}
function onDragOver(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) editorShell.classList.remove('is-dragover');
}
async function onDrop(event) {
if (!event.dataTransfer?.files?.length) return;
event.preventDefault();
dragDepth = 0;
editorShell.classList.remove('is-dragover');
await insertUploadedFiles(event.dataTransfer.files);
}
async function onFileInputChange(event) {
const files = event.target?.files;
if (!files || files.length === 0) return;
await insertUploadedFiles(files);
fileInput.value = '';
}
async function onPaste(event) {
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (imageFiles.length === 0) return;
event.preventDefault();
await insertUploadedFiles(imageFiles);
}
function onMentionDropdownMouseDown(event) {
const item = event.target.closest('.post-composer-mention-item');
if (!item) return;
event.preventDefault();
const index = Number(item.dataset.index);
if (Number.isNaN(index)) return;
const selected = mentionState.candidates[index];
if (selected) replaceMentionWithProfile(selected);
}
async function onSendClick() {
/* In stop mode, the button acts as Stop — route to the stop handler. */
if (stopModeActive) {
if (stopModeHandler) stopModeHandler();
return;
}
if (sendButton.disabled) return;
if (!onSubmit) return;
const text = (hostEl.innerText || '').trim();
const attachments = pendingAttachments.slice();
if (!text && attachments.length === 0) return;
const previousText = text;
const previousAttachments = attachments;
if (clearBeforeSubmit) {
clearComposerContent();
if (!autoHideOnSubmit) {
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
}
try {
const submitted = await onSubmit(previousText, { attachments: previousAttachments });
if (submitted === false) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
}
return;
}
if (previousText) {
pushToHistory(previousText);
}
if (autoHideOnSubmit) {
if (!clearBeforeSubmit) {
clearComposerContent();
}
hideComposer();
} else if (!clearBeforeSubmit) {
pendingAttachments = [];
queuePreviewRender();
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
} catch (error) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
}
console.error('[post-composer] Submit failed:', error);
}
}
hostEl.addEventListener('input', onInput);
hostEl.addEventListener('keydown', onKeydown);
hostEl.addEventListener('dragenter', onDragEnter);
hostEl.addEventListener('dragover', onDragOver);
hostEl.addEventListener('dragleave', onDragLeave);
hostEl.addEventListener('drop', onDrop);
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('mousedown', onDocumentClick);
hostEl.addEventListener('paste', onPaste);
if (uploadButton) {
uploadButton.addEventListener('click', () => fileInput.click());
}
fileInput.addEventListener('change', onFileInputChange);
mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.addEventListener('click', onSendClick);
queuePreviewRender();
updateSendButtonVisibility();
return {
refresh(nextOptions = {}) {
if (Array.isArray(nextOptions.followedProfiles)) {
followedProfiles = nextOptions.followedProfiles.slice();
}
queuePreviewRender();
},
clear() {
clearComposerContent();
},
setDisabled(disabled) {
externallyDisabled = !!disabled;
updateSendButtonVisibility();
},
/* Convert the send button into a stop button. The provided handler
* is called when the user clicks the (now stop) button. The text
* area is NOT disabled — the user can type their next message while
* the agent runs, but the button acts as Stop until clearStopMode()
* is called. */
setStopMode(handler) {
stopModeActive = true;
stopModeHandler = typeof handler === 'function' ? handler : null;
sendButton.textContent = stopLabel;
sendButton.classList.add('is-stop-mode');
sendButton.disabled = false;
sendButton.classList.add('is-visible');
},
/* Revert the stop button back to a send button. */
clearStopMode() {
stopModeActive = false;
stopModeHandler = null;
sendButton.textContent = sendLabel;
sendButton.classList.remove('is-stop-mode');
updateSendButtonVisibility();
},
show() {
showComposer();
},
hide() {
hideComposer();
},
isUploading() {
return uploadingCount > 0;
},
destroy() {
hostEl.removeEventListener('input', onInput);
hostEl.removeEventListener('keydown', onKeydown);
hostEl.removeEventListener('dragenter', onDragEnter);
hostEl.removeEventListener('dragover', onDragOver);
hostEl.removeEventListener('dragleave', onDragLeave);
hostEl.removeEventListener('drop', onDrop);
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('mousedown', onDocumentClick);
hostEl.removeEventListener('paste', onPaste);
fileInput.removeEventListener('change', onFileInputChange);
mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.removeEventListener('click', onSendClick);
if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown);
if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton);
if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay);
if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus);
if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl);
if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton);
if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell);
if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput);
}
};
}
window.mountComposerDefault = window.mountComposer;
+894
View File
@@ -0,0 +1,894 @@
/* Inlined stubs for sovereign_browser — WebKit's custom sovereign://
* scheme does not resolve relative ES module imports, so we inline the
* dependencies instead of importing from separate .mjs files. These
* stubs are sufficient for the chat composer (no uploads, no preview). */
function uploadToAllServers() {
return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser'));
}
function getBlobUrl(sha256, ext) {
return `blossom://${sha256}.${ext}`;
}
function htmlFormatText(text = '') {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function hydrateNostrEntities() {
/* No-op in sovereign_browser. */
}
function debounce(fn, delay = 250) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function getFileExtension(file) {
const fromName = String(file?.name || '').split('.').pop();
if (fromName && fromName !== file?.name) return fromName.toLowerCase();
const fromType = String(file?.type || '').split('/').pop();
return fromType ? fromType.toLowerCase() : '';
}
function getCaretCharacterOffsetWithin(element) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
function setCaretByCharacterOffset(element, offset) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let currentOffset = 0;
let found = false;
const walk = (node) => {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length || 0;
if (currentOffset + textLength >= offset) {
range.setStart(node, Math.max(0, offset - currentOffset));
range.collapse(true);
found = true;
} else {
currentOffset += textLength;
}
return;
}
for (const child of node.childNodes) {
walk(child);
if (found) return;
}
};
walk(element);
if (!found) {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
function detectMentionQuery(text, caretOffset) {
const left = text.slice(0, caretOffset);
const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i);
if (!match) return null;
const query = match[2] || '';
const start = left.length - query.length - 1;
const end = caretOffset;
return { query, start, end };
}
function escapeHtml(text = '') {
return String(text)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/'/g, '\u0026#39;');
}
function toNpub(pubkeyOrNpub) {
if (!pubkeyOrNpub) return null;
if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub);
try {
return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null;
} catch {
return null;
}
}
function positionDropdownNearCaret(hostEl, dropdownEl) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
let rect = range.getBoundingClientRect();
if (!rect || (!rect.width && !rect.height)) {
const hostRect = hostEl.getBoundingClientRect();
rect = {
left: hostRect.left + 8,
top: hostRect.top + hostRect.height - 8,
bottom: hostRect.top + hostRect.height - 8
};
}
dropdownEl.style.left = `${Math.max(8, rect.left)}px`;
dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`;
}
function filterMentionCandidates(followedProfiles, query) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
return followedProfiles
.filter((p) => {
const name = String(p?.name || '').toLowerCase();
const display = String(p?.display_name || '').toLowerCase();
const nip05 = String(p?.nip05 || '').toLowerCase();
const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase();
return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q);
})
.slice(0, 8);
}
function createUploadIcon(kind = 'upload') {
const normalizedKind = kind === 'image' ? 'image' : 'upload';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'post-composer-upload-icon';
if (normalizedKind === 'image') {
btn.classList.add('is-image-icon');
}
if (normalizedKind === 'image') {
btn.title = 'Attach image';
btn.setAttribute('aria-label', 'Attach image');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2"/>
<circle cx="9" cy="10" r="1.5"/>
<path d="M6 16l4-4 3 3 3-3 2 2"/>
</svg>
`;
return btn;
}
btn.title = 'Upload file';
btn.setAttribute('aria-label', 'Upload file');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<path d="M7 10l5-5 5 5"/>
<path d="M12 5v12"/>
</svg>
`;
return btn;
}
export function mountComposer(hostEl, options = {}) {
if (!hostEl) throw new Error('mountComposer requires a host element');
let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : [];
const showUploadIcon = options.showUploadIcon !== false;
const showPreview = options.showPreview !== false;
const autoHideOnSubmit = options.autoHideOnSubmit === true;
const submitOnEnter = options.submitOnEnter === true;
const hideOnEscape = options.hideOnEscape === true;
const alwaysShowSendButton = options.alwaysShowSendButton === true;
const clearBeforeSubmit = options.clearBeforeSubmit !== false;
const enableHistory = options.enableHistory !== false;
const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim()
? options.historyKey.trim()
: 'post_composer_history_v1';
const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50)));
const layout = options.layout === 'inline' ? 'inline' : 'default';
const popupMode = options.popupMode === true;
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null;
const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url';
const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload');
const defaultFileAccept = uploadMode === 'direct-image'
? 'image/*'
: 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip';
const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim()
? options.fileAccept.trim()
: defaultFileAccept;
hostEl.setAttribute('contenteditable', 'true');
let wrapper = hostEl.parentElement;
if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'post-composer-wrapper';
hostEl.parentNode?.insertBefore(wrapper, hostEl);
wrapper.appendChild(hostEl);
}
const editorShell = document.createElement('div');
editorShell.className = 'post-composer-editor-shell';
wrapper.insertBefore(editorShell, hostEl);
editorShell.appendChild(hostEl);
const dragOverlay = document.createElement('div');
dragOverlay.className = 'post-composer-drag-overlay';
dragOverlay.textContent = 'Drop files to upload';
editorShell.appendChild(dragOverlay);
const uploadStatus = document.createElement('div');
uploadStatus.className = 'post-composer-uploading';
uploadStatus.style.display = 'none';
editorShell.appendChild(uploadStatus);
let uploadButton = null;
if (showUploadIcon) {
uploadButton = createUploadIcon(uploadIcon);
editorShell.appendChild(uploadButton);
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = fileAccept;
fileInput.style.display = 'none';
wrapper.appendChild(fileInput);
const previewEl = document.createElement('div');
previewEl.className = 'post-composer-preview';
previewEl.innerHTML = `
<div class="post-composer-preview-label">Preview</div>
<div class="post-composer-preview-content divPostContent"></div>
`;
const previewContentEl = previewEl.querySelector('.post-composer-preview-content');
const sendButton = document.createElement('button');
sendButton.type = 'button';
sendButton.className = 'post-composer-send-btn';
if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible');
sendButton.textContent = 'Send';
sendButton.disabled = true;
/* Stop-mode state: when active, the send button transforms into a
* stop button (different label, click handler, and styling). This is
* used by the agent chat to let the user cancel an in-progress agent
* run without needing a separate stop button. */
let stopModeActive = false;
let stopModeHandler = null;
const sendLabel = 'Send';
const stopLabel = 'Stop';
if (layout === 'inline') {
wrapper.classList.add('post-composer-wrapper--inline');
if (showPreview) wrapper.appendChild(previewEl);
const inlineRow = document.createElement('div');
inlineRow.className = 'post-composer-inline-row';
wrapper.appendChild(inlineRow);
inlineRow.appendChild(editorShell);
inlineRow.appendChild(sendButton);
} else if (popupMode) {
wrapper.classList.add('post-composer-wrapper--popup');
const scrollBody = document.createElement('div');
scrollBody.className = 'post-composer-popup-body';
scrollBody.appendChild(editorShell);
if (showPreview) scrollBody.appendChild(previewEl);
wrapper.appendChild(scrollBody);
const stickyFooter = document.createElement('div');
stickyFooter.className = 'post-composer-popup-footer';
stickyFooter.appendChild(sendButton);
wrapper.appendChild(stickyFooter);
} else {
if (showPreview) wrapper.appendChild(previewEl);
wrapper.appendChild(sendButton);
}
const mentionDropdown = document.createElement('div');
mentionDropdown.className = 'post-composer-mention-dropdown';
document.body.appendChild(mentionDropdown);
let dragDepth = 0;
let uploadingCount = 0;
let externallyDisabled = options.disabled === true;
let pendingAttachments = [];
let mentionState = {
query: '',
start: -1,
end: -1,
candidates: [],
activeIndex: 0,
visible: false
};
function loadHistory() {
if (!enableHistory) return [];
try {
const raw = localStorage.getItem(historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function saveHistory(nextHistory) {
if (!enableHistory) return;
try {
localStorage.setItem(historyKey, JSON.stringify(nextHistory));
} catch {
// Ignore localStorage failures.
}
}
let history = loadHistory();
let historyIndex = -1;
let historyDraft = '';
function pushToHistory(text) {
if (!enableHistory) return;
const normalized = String(text || '').trim();
if (!normalized) return;
history = history.filter((item) => item !== normalized);
history.unshift(normalized);
if (history.length > historyMaxEntries) {
history = history.slice(0, historyMaxEntries);
}
saveHistory(history);
historyIndex = -1;
historyDraft = '';
}
function resetHistoryBrowseState() {
historyIndex = -1;
historyDraft = '';
}
function setEditorText(text = '') {
hostEl.innerText = String(text || '');
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
renderPreview();
updateSendButtonVisibility();
}
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
return;
}
if (pendingAttachments.length > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`;
return;
}
uploadStatus.style.display = 'none';
uploadStatus.textContent = '';
}
function hideMentionDropdown() {
mentionState.visible = false;
mentionState.candidates = [];
mentionState.activeIndex = 0;
mentionDropdown.classList.remove('is-visible');
mentionDropdown.innerHTML = '';
}
function renderMentionDropdown() {
if (!mentionState.visible || mentionState.candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionDropdown.innerHTML = mentionState.candidates
.map((profile, idx) => {
const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}…`;
const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}…`;
const avatar = profile.picture
? `<img class="post-composer-mention-avatar" src="${escapeHtml(profile.picture)}" alt="" onerror="this.style.visibility='hidden'" />`
: `<div class="post-composer-mention-avatar" style="background:var(--muted-color);"></div>`;
return `
<div class="post-composer-mention-item ${idx === mentionState.activeIndex ? 'is-active' : ''}" data-index="${idx}">
${avatar}
<div class="post-composer-mention-meta">
<div class="post-composer-mention-name">${escapeHtml(display)}</div>
<div class="post-composer-mention-handle">${escapeHtml(handle)}</div>
</div>
</div>
`;
})
.join('');
mentionDropdown.classList.add('is-visible');
positionDropdownNearCaret(hostEl, mentionDropdown);
}
function replaceMentionWithProfile(profile) {
const npub = toNpub(profile?.npub || profile?.pubkey);
if (!npub) return;
const currentText = hostEl.innerText || '';
if (mentionState.start < 0 || mentionState.end < mentionState.start) return;
const replacement = `nostr:${npub}`;
const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end);
hostEl.innerText = nextText;
const nextCaret = mentionState.start + replacement.length;
setCaretByCharacterOffset(hostEl, nextCaret);
hideMentionDropdown();
queuePreviewRender();
updateSendButtonVisibility();
}
function updateMentionStateFromCaret() {
const text = hostEl.innerText || '';
const caret = getCaretCharacterOffsetWithin(hostEl);
const mention = detectMentionQuery(text, caret);
if (!mention) {
hideMentionDropdown();
return;
}
const candidates = filterMentionCandidates(followedProfiles, mention.query);
if (candidates.length === 0) {
hideMentionDropdown();
return;
}
mentionState = {
...mention,
candidates,
activeIndex: 0,
visible: true
};
renderMentionDropdown();
}
function normalizePendingAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const dataUrl = String(att?.dataUrl || '').trim();
if (!dataUrl.startsWith('data:image/')) return null;
return {
id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`),
name: String(att?.name || 'image').trim() || 'image',
mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined),
dataUrl
};
})
.filter(Boolean);
}
async function insertUploadedFiles(files) {
const arr = Array.from(files || []).filter((f) => f && f.size > 0);
if (arr.length === 0) return;
uploadingCount += arr.length;
updateUploadingUi();
try {
if (uploadMode === 'direct-image') {
if (!onFileAttach) {
console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.');
return;
}
const attached = await onFileAttach(arr);
const normalized = normalizePendingAttachments(attached);
if (normalized.length > 0) {
pendingAttachments = [...pendingAttachments, ...normalized];
}
updateSendButtonVisibility();
return;
}
for (const file of arr) {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);
updateUploadingUi();
queuePreviewRender();
}
}
function updateSendButtonVisibility() {
const hasContent = !!(hostEl.innerText || '').trim();
const hasAttachments = pendingAttachments.length > 0;
const canSubmit = hasContent || hasAttachments;
const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled;
sendButton.classList.toggle('is-visible', canSubmit);
if (alwaysShowSendButton) sendButton.classList.add('is-visible');
sendButton.disabled = !interactive;
sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1;
}
function clearComposerContent() {
hostEl.innerHTML = '';
hostEl.innerText = '';
pendingAttachments = [];
renderPreview();
updateUploadingUi();
updateSendButtonVisibility();
hideMentionDropdown();
resetHistoryBrowseState();
}
function hideComposer() {
wrapper.style.display = 'none';
}
function showComposer() {
wrapper.style.display = '';
}
function renderPreview() {
if (!showPreview || !previewContentEl) return;
const raw = hostEl.innerText || '';
if (!raw.trim()) {
previewEl.classList.remove('is-visible');
previewContentEl.innerHTML = '';
return;
}
previewEl.classList.add('is-visible');
previewContentEl.innerHTML = htmlFormatText(raw);
hydrateNostrEntities(previewContentEl);
}
const queuePreviewRender = debounce(renderPreview, 250);
function onInput() {
queuePreviewRender();
updateMentionStateFromCaret();
updateSendButtonVisibility();
if (historyIndex !== -1) {
resetHistoryBrowseState();
}
}
function onKeydown(event) {
if (event.key === 'Tab' && !event.shiftKey) {
const hasContent = !!(hostEl.innerText || '').trim();
if (hasContent && !mentionState.visible) {
event.preventDefault();
sendButton.focus();
return;
}
}
const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter';
if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) {
event.preventDefault();
event.stopPropagation();
onSendClick();
return;
}
if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) {
event.preventDefault();
onSendClick();
return;
}
if (event.key === 'Escape') {
if (mentionState.visible) {
event.preventDefault();
hideMentionDropdown();
return;
}
if (hideOnEscape) {
event.preventDefault();
hideComposer();
return;
}
}
if (!mentionState.visible && enableHistory) {
const currentText = hostEl.innerText || '';
const caretOffset = getCaretCharacterOffsetWithin(hostEl);
const singleLine = !currentText.includes('\n');
if (event.key === 'ArrowUp') {
const atStart = caretOffset <= 0;
if (atStart || singleLine) {
event.preventDefault();
if (historyIndex === -1) {
historyDraft = currentText;
}
if (historyIndex < history.length - 1) {
historyIndex += 1;
setEditorText(history[historyIndex] || '');
}
return;
}
}
if (event.key === 'ArrowDown') {
const atEnd = caretOffset >= currentText.length;
if ((atEnd || singleLine) && historyIndex >= 0) {
event.preventDefault();
historyIndex -= 1;
if (historyIndex < 0) {
setEditorText(historyDraft || '');
} else {
setEditorText(history[historyIndex] || '');
}
return;
}
}
}
if (!mentionState.visible) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length;
renderMentionDropdown();
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const selected = mentionState.candidates[mentionState.activeIndex];
if (selected) replaceMentionWithProfile(selected);
return;
}
}
function onSelectionChange() {
if (!mentionState.visible) return;
updateMentionStateFromCaret();
}
function onDocumentClick(event) {
if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return;
hideMentionDropdown();
}
function onDragEnter(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth += 1;
editorShell.classList.add('is-dragover');
}
function onDragOver(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) editorShell.classList.remove('is-dragover');
}
async function onDrop(event) {
if (!event.dataTransfer?.files?.length) return;
event.preventDefault();
dragDepth = 0;
editorShell.classList.remove('is-dragover');
await insertUploadedFiles(event.dataTransfer.files);
}
async function onFileInputChange(event) {
const files = event.target?.files;
if (!files || files.length === 0) return;
await insertUploadedFiles(files);
fileInput.value = '';
}
async function onPaste(event) {
const clipboardItems = Array.from(event.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (imageFiles.length === 0) return;
event.preventDefault();
await insertUploadedFiles(imageFiles);
}
function onMentionDropdownMouseDown(event) {
const item = event.target.closest('.post-composer-mention-item');
if (!item) return;
event.preventDefault();
const index = Number(item.dataset.index);
if (Number.isNaN(index)) return;
const selected = mentionState.candidates[index];
if (selected) replaceMentionWithProfile(selected);
}
async function onSendClick() {
/* In stop mode, the button acts as Stop — route to the stop handler. */
if (stopModeActive) {
if (stopModeHandler) stopModeHandler();
return;
}
if (sendButton.disabled) return;
if (!onSubmit) return;
const text = (hostEl.innerText || '').trim();
const attachments = pendingAttachments.slice();
if (!text && attachments.length === 0) return;
const previousText = text;
const previousAttachments = attachments;
if (clearBeforeSubmit) {
clearComposerContent();
if (!autoHideOnSubmit) {
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
}
try {
const submitted = await onSubmit(previousText, { attachments: previousAttachments });
if (submitted === false) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
}
return;
}
if (previousText) {
pushToHistory(previousText);
}
if (autoHideOnSubmit) {
if (!clearBeforeSubmit) {
clearComposerContent();
}
hideComposer();
} else if (!clearBeforeSubmit) {
pendingAttachments = [];
queuePreviewRender();
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, 0);
}
} catch (error) {
if (clearBeforeSubmit) {
setEditorText(previousText);
pendingAttachments = previousAttachments;
updateUploadingUi();
updateSendButtonVisibility();
hostEl.focus();
setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
}
console.error('[post-composer] Submit failed:', error);
}
}
hostEl.addEventListener('input', onInput);
hostEl.addEventListener('keydown', onKeydown);
hostEl.addEventListener('dragenter', onDragEnter);
hostEl.addEventListener('dragover', onDragOver);
hostEl.addEventListener('dragleave', onDragLeave);
hostEl.addEventListener('drop', onDrop);
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('mousedown', onDocumentClick);
hostEl.addEventListener('paste', onPaste);
if (uploadButton) {
uploadButton.addEventListener('click', () => fileInput.click());
}
fileInput.addEventListener('change', onFileInputChange);
mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.addEventListener('click', onSendClick);
queuePreviewRender();
updateSendButtonVisibility();
return {
refresh(nextOptions = {}) {
if (Array.isArray(nextOptions.followedProfiles)) {
followedProfiles = nextOptions.followedProfiles.slice();
}
queuePreviewRender();
},
clear() {
clearComposerContent();
},
setDisabled(disabled) {
externallyDisabled = !!disabled;
updateSendButtonVisibility();
},
setStopMode(handler) {
stopModeActive = true;
stopModeHandler = typeof handler === 'function' ? handler : null;
sendButton.textContent = stopLabel;
sendButton.classList.add('is-stop-mode');
sendButton.disabled = false;
sendButton.classList.add('is-visible');
},
clearStopMode() {
stopModeActive = false;
stopModeHandler = null;
sendButton.textContent = sendLabel;
sendButton.classList.remove('is-stop-mode');
updateSendButtonVisibility();
},
show() {
showComposer();
},
hide() {
hideComposer();
},
isUploading() {
return uploadingCount > 0;
},
destroy() {
hostEl.removeEventListener('input', onInput);
hostEl.removeEventListener('keydown', onKeydown);
hostEl.removeEventListener('dragenter', onDragEnter);
hostEl.removeEventListener('dragover', onDragOver);
hostEl.removeEventListener('dragleave', onDragLeave);
hostEl.removeEventListener('drop', onDrop);
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('mousedown', onDocumentClick);
hostEl.removeEventListener('paste', onPaste);
fileInput.removeEventListener('change', onFileInputChange);
mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown);
sendButton.removeEventListener('click', onSendClick);
if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown);
if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton);
if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay);
if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus);
if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl);
if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton);
if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell);
if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput);
}
};
}
export default mountComposer;
+212
View File
@@ -0,0 +1,212 @@
:root {
color-scheme: light dark;
--jv-bg: #ffffff;
--jv-fg: #24292f;
--jv-muted: #6e7781;
--jv-border: #d0d7de;
--jv-guide: #d8dee4;
--jv-hover: #f3f4f6;
--jv-control-bg: #f6f8fa;
--jv-control-hover: #eaeef2;
--jv-string: #16803a;
--jv-number: #0969da;
--jv-boolean: #bc4c00;
--jv-null: #6e7781;
--jv-key: #8250df;
--jv-match: #fff3a3;
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
min-height: 100%;
background: var(--jv-bg);
color: var(--jv-fg);
}
body.json-viewer-page {
font-family: Menlo, Consolas, "Liberation Mono", monospace;
font-size: 14px;
line-height: 1.55;
}
.json-viewer { min-height: 100vh; }
.json-viewer__toolbar {
position: sticky;
z-index: 10;
top: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--jv-border);
background: color-mix(in srgb, var(--jv-bg) 94%, transparent);
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
}
.json-viewer__title {
margin-right: auto;
font-weight: 700;
}
.json-viewer__button,
.json-viewer__search {
min-height: 32px;
border: 1px solid var(--jv-border);
border-radius: 6px;
background: var(--jv-control-bg);
color: var(--jv-fg);
font: inherit;
}
.json-viewer__button {
padding: 4px 10px;
cursor: pointer;
}
.json-viewer__button:hover { background: var(--jv-control-hover); }
.json-viewer__button:focus-visible,
.json-viewer__search:focus-visible {
outline: 2px solid var(--jv-number);
outline-offset: 1px;
}
.json-viewer__helper-apps {
display: flex;
align-items: center;
gap: 6px;
}
.json-viewer__helper-label {
color: var(--jv-muted);
font-size: 12px;
}
.json-viewer__helper-link {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.json-viewer__search {
width: min(280px, 50vw);
padding: 5px 10px;
}
.json-viewer__status {
min-width: 9em;
color: var(--jv-muted);
font-size: 12px;
}
.json-viewer__tree {
overflow-x: auto;
overflow-y: visible;
padding: 14px 16px 40px;
}
.json-viewer__node { min-width: 0; }
.json-viewer__children {
margin-left: 8px;
padding-left: 17px;
border-left: 1px solid var(--jv-guide);
}
.json-viewer__node.is-collapsed > .json-viewer__children { display: none; }
.json-viewer__row {
display: flex;
align-items: baseline;
min-height: 23px;
padding: 1px 7px 1px 2px;
border-radius: 4px;
white-space: pre;
}
.json-viewer__row:hover { background: var(--jv-hover); }
.json-viewer__node.is-filtered-out { display: none; }
.json-viewer__node.is-match > .json-viewer__row { background: var(--jv-match); }
.json-viewer__toggle {
width: 18px;
flex: 0 0 18px;
padding: 0;
border: 0;
background: transparent;
color: var(--jv-muted);
cursor: pointer;
font: inherit;
line-height: 1;
}
.json-viewer__toggle::before { content: "▼"; }
.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__toggle::before { content: "▶"; }
.json-viewer__toggle--empty { cursor: default; }
.json-viewer__toggle--empty::before { content: ""; }
.json-viewer__key { color: var(--jv-key); }
.json-viewer__key::after { content: ":"; color: var(--jv-fg); }
.json-viewer__punctuation { color: var(--jv-fg); }
.json-viewer__string {
min-width: 0;
color: var(--jv-string);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-all;
}
.json-viewer__number { color: var(--jv-number); }
.json-viewer__boolean { color: var(--jv-boolean); font-weight: 600; }
.json-viewer__null { color: var(--jv-null); font-style: italic; }
.json-viewer__badge {
display: none;
margin-left: 7px;
padding: 0 6px;
border: 1px solid var(--jv-border);
border-radius: 999px;
color: var(--jv-muted);
font-size: 11px;
line-height: 17px;
}
.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__badge { display: inline; }
.json-viewer__preview {
display: none;
margin-left: 7px;
color: var(--jv-muted);
}
.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__preview { display: inline; }
@media (prefers-color-scheme: dark) {
:root {
--jv-bg: #1e1e1e;
--jv-fg: #d4d4d4;
--jv-muted: #9b9b9b;
--jv-border: #454545;
--jv-guide: #3f3f46;
--jv-hover: #2a2d2e;
--jv-control-bg: #2d2d30;
--jv-control-hover: #3a3d41;
--jv-string: #6a9955;
--jv-number: #569cd6;
--jv-boolean: #ce9178;
--jv-null: #9b9b9b;
--jv-key: #9cdcfe;
--jv-match: #514b20;
}
}
@media (max-width: 700px) {
.json-viewer__title { width: 100%; }
.json-viewer__search { order: 2; width: 100%; }
.json-viewer__status { order: 3; }
.json-viewer__helper-apps { width: 100%; flex-wrap: wrap; }
}
+233
View File
@@ -0,0 +1,233 @@
(function () {
'use strict';
if (document.contentType !== 'application/json' || document.querySelector('.json-viewer')) {
return;
}
var CSS = `:root{color-scheme:light dark;--jv-bg:#fff;--jv-fg:#24292f;--jv-muted:#6e7781;--jv-border:#d0d7de;--jv-guide:#d8dee4;--jv-hover:#f3f4f6;--jv-control-bg:#f6f8fa;--jv-control-hover:#eaeef2;--jv-string:#16803a;--jv-number:#0969da;--jv-boolean:#bc4c00;--jv-null:#6e7781;--jv-key:#8250df;--jv-match:#fff3a3}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--jv-bg);color:var(--jv-fg)}body.json-viewer-page{font-family:Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;line-height:1.55}.json-viewer{min-height:100vh}.json-viewer__toolbar{position:sticky;z-index:10;top:0;display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--jv-border);background:var(--jv-bg);box-shadow:0 1px 4px rgb(0 0 0/8%)}.json-viewer__title{margin-right:auto;font-weight:700}.json-viewer__button,.json-viewer__search{min-height:32px;border:1px solid var(--jv-border);border-radius:6px;background:var(--jv-control-bg);color:var(--jv-fg);font:inherit}.json-viewer__button{padding:4px 10px;cursor:pointer}.json-viewer__button:hover{background:var(--jv-control-hover)}.json-viewer__button:focus-visible,.json-viewer__search:focus-visible{outline:2px solid var(--jv-number);outline-offset:1px}.json-viewer__helper-apps{display:flex;align-items:center;gap:6px}.json-viewer__helper-label{color:var(--jv-muted);font-size:12px}.json-viewer__helper-link{display:inline-flex;align-items:center;text-decoration:none}.json-viewer__search{width:min(280px,50vw);padding:5px 10px}.json-viewer__status{min-width:9em;color:var(--jv-muted);font-size:12px}.json-viewer__tree{overflow-x:auto;overflow-y:visible;padding:14px 16px 40px}.json-viewer__node{min-width:0}.json-viewer__children{margin-left:8px;padding-left:17px;border-left:1px solid var(--jv-guide)}.json-viewer__node.is-collapsed>.json-viewer__children{display:none}.json-viewer__row{display:flex;align-items:baseline;min-height:23px;padding:1px 7px 1px 2px;border-radius:4px;white-space:pre}.json-viewer__row:hover{background:var(--jv-hover)}.json-viewer__node.is-filtered-out{display:none}.json-viewer__node.is-match>.json-viewer__row{background:var(--jv-match)}.json-viewer__toggle{width:18px;flex:0 0 18px;padding:0;border:0;background:transparent;color:var(--jv-muted);cursor:pointer;font:inherit;line-height:1}.json-viewer__toggle:before{content:"▼"}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__toggle:before{content:"▶"}.json-viewer__toggle--empty{cursor:default}.json-viewer__toggle--empty:before{content:""}.json-viewer__key{color:var(--jv-key)}.json-viewer__key:after{content:":";color:var(--jv-fg)}.json-viewer__punctuation{color:var(--jv-fg)}.json-viewer__string{min-width:0;color:var(--jv-string);white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;word-break:break-all}.json-viewer__number{color:var(--jv-number)}.json-viewer__boolean{color:var(--jv-boolean);font-weight:600}.json-viewer__null{color:var(--jv-null);font-style:italic}.json-viewer__badge{display:none;margin-left:7px;padding:0 6px;border:1px solid var(--jv-border);border-radius:999px;color:var(--jv-muted);font-size:11px;line-height:17px}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__badge{display:inline}.json-viewer__preview{display:none;margin-left:7px;color:var(--jv-muted)}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__preview{display:inline}@media(prefers-color-scheme:dark){:root{--jv-bg:#1e1e1e;--jv-fg:#d4d4d4;--jv-muted:#9b9b9b;--jv-border:#454545;--jv-guide:#3f3f46;--jv-hover:#2a2d2e;--jv-control-bg:#2d2d30;--jv-control-hover:#3a3d41;--jv-string:#6a9955;--jv-number:#569cd6;--jv-boolean:#ce9178;--jv-null:#9b9b9b;--jv-key:#9cdcfe;--jv-match:#514b20}}@media(max-width:700px){.json-viewer__title{width:100%}.json-viewer__search{order:2;width:100%}.json-viewer__status{order:3}.json-viewer__helper-apps{width:100%;flex-wrap:wrap}}`;
function element(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function valueType(value) {
if (value === null) return 'null';
return typeof value;
}
function primitive(value) {
var type = valueType(value);
var text = type === 'string' ? JSON.stringify(value) : String(value);
return element('span', 'json-viewer__' + type, text);
}
function countLabel(value) {
var count = Array.isArray(value) ? value.length : Object.keys(value).length;
return count + ' ' + (Array.isArray(value) ? (count === 1 ? 'item' : 'items') : (count === 1 ? 'key' : 'keys'));
}
function buildNode(value, key, path) {
var compound = value !== null && typeof value === 'object';
var node = element('div', 'json-viewer__node');
node.dataset.key = key === null ? '' : String(key).toLocaleLowerCase();
node.dataset.path = path.toLocaleLowerCase();
var row = element('div', 'json-viewer__row');
var toggle = element('button', 'json-viewer__toggle' + (compound ? '' : ' json-viewer__toggle--empty'));
toggle.type = 'button';
toggle.tabIndex = compound ? 0 : -1;
toggle.setAttribute('aria-label', compound ? 'Collapse node' : 'Value');
row.appendChild(toggle);
if (key !== null) {
row.appendChild(element('span', 'json-viewer__key', JSON.stringify(String(key))));
row.appendChild(document.createTextNode(' '));
}
if (!compound) {
row.appendChild(primitive(value));
node.appendChild(row);
return node;
}
var isArray = Array.isArray(value);
var opening = isArray ? '[' : '{';
var closing = isArray ? ']' : '}';
row.appendChild(element('span', 'json-viewer__punctuation', opening));
row.appendChild(element('span', 'json-viewer__badge', countLabel(value)));
row.appendChild(element('span', 'json-viewer__preview', '… ' + closing));
node.appendChild(row);
var children = element('div', 'json-viewer__children');
var entries = isArray ? value.map(function (item, index) { return [index, item]; }) : Object.keys(value).map(function (itemKey) { return [itemKey, value[itemKey]]; });
entries.forEach(function (entry) {
var childPath = path ? path + '.' + entry[0] : String(entry[0]);
children.appendChild(buildNode(entry[1], isArray ? null : entry[0], childPath));
});
children.appendChild(element('div', 'json-viewer__row json-viewer__punctuation', ' ' + closing));
node.appendChild(children);
toggle.addEventListener('click', function () {
var collapsed = node.classList.toggle('is-collapsed');
toggle.setAttribute('aria-label', collapsed ? 'Expand node' : 'Collapse node');
});
return node;
}
function setAllCollapsed(root, collapsed) {
root.querySelectorAll('.json-viewer__node').forEach(function (node) {
if (node.querySelector(':scope > .json-viewer__children')) {
node.classList.toggle('is-collapsed', collapsed);
var button = node.querySelector(':scope > .json-viewer__row > .json-viewer__toggle');
if (button) button.setAttribute('aria-label', collapsed ? 'Expand node' : 'Collapse node');
}
});
}
function copyText(text, button) {
var copied = function () {
var old = button.textContent;
button.textContent = 'Copied!';
window.setTimeout(function () { button.textContent = old; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(copied).catch(function () { fallbackCopy(text, copied); });
} else {
fallbackCopy(text, copied);
}
}
function fallbackCopy(text, done) {
var area = element('textarea');
area.value = text;
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); done(); } catch (ignore) { /* no-op */ }
area.remove();
}
function addNostrHelperApps(toolbar) {
var match = location.href.match(/^nostr:\/\/([^/?#]+)/i);
if (!match || match[1] === 'helper-apps') return;
var entity = decodeURIComponent(match[1]);
fetch('nostr://' + encodeURIComponent(entity) + '/helper-apps')
.then(function (response) {
if (!response.ok) throw new Error('Unable to load Nostr helper apps');
return response.json();
})
.then(function (apps) {
if (!Array.isArray(apps) || !apps.length) return;
var group = element('span', 'json-viewer__helper-apps');
group.appendChild(element('span', 'json-viewer__helper-label', 'Open in:'));
apps.forEach(function (app) {
if (!app || typeof app.name !== 'string' || typeof app.url_template !== 'string') return;
var url = app.url_template.split('{entity}').join(entity);
var link = element('a', 'json-viewer__button json-viewer__helper-link', app.name);
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.addEventListener('click', function (event) {
event.preventDefault();
window.open(url, '_blank', 'noopener');
});
group.appendChild(link);
});
if (group.children.length > 1) toolbar.appendChild(group);
})
.catch(function () { /* Keep the standard JSON toolbar on endpoint failure. */ });
}
var source = document.querySelector('body > pre') || document.querySelector('pre');
if (!source) return;
var raw = source.textContent;
var data;
try {
data = JSON.parse(raw);
} catch (error) {
return;
}
var style = element('style');
style.id = 'sovereign-json-viewer-styles';
style.textContent = CSS;
(document.head || document.documentElement).appendChild(style);
var viewer = element('main', 'json-viewer');
var toolbar = element('div', 'json-viewer__toolbar');
toolbar.appendChild(element('span', 'json-viewer__title', 'JSON Viewer'));
var copyButton = element('button', 'json-viewer__button', 'Copy JSON');
copyButton.type = 'button';
copyButton.addEventListener('click', function () { copyText(raw, copyButton); });
toolbar.appendChild(copyButton);
var collapseButton = element('button', 'json-viewer__button', 'Collapse all');
collapseButton.type = 'button';
toolbar.appendChild(collapseButton);
var expandButton = element('button', 'json-viewer__button', 'Expand all');
expandButton.type = 'button';
toolbar.appendChild(expandButton);
addNostrHelperApps(toolbar);
var search = element('input', 'json-viewer__search');
search.type = 'search';
search.placeholder = 'Filter keys…';
search.setAttribute('aria-label', 'Filter JSON keys');
toolbar.appendChild(search);
var status = element('span', 'json-viewer__status');
status.setAttribute('aria-live', 'polite');
toolbar.appendChild(status);
viewer.appendChild(toolbar);
var tree = element('div', 'json-viewer__tree');
tree.setAttribute('role', 'tree');
tree.appendChild(buildNode(data, null, ''));
viewer.appendChild(tree);
collapseButton.addEventListener('click', function () { setAllCollapsed(tree, true); });
expandButton.addEventListener('click', function () { setAllCollapsed(tree, false); });
search.addEventListener('input', function () {
var query = search.value.trim().toLocaleLowerCase();
var nodes = Array.prototype.slice.call(tree.querySelectorAll('.json-viewer__node'));
var matches = 0;
nodes.forEach(function (node) {
node.classList.remove('is-filtered-out', 'is-match');
});
if (!query) {
status.textContent = '';
return;
}
nodes.forEach(function (node) {
var directMatch = node.dataset.key.indexOf(query) !== -1;
node.classList.toggle('is-match', directMatch);
if (directMatch) {
matches += 1;
var parent = node.parentElement;
while (parent && parent !== tree) {
if (parent.classList.contains('json-viewer__node')) parent.classList.remove('is-collapsed');
parent = parent.parentElement;
}
}
});
nodes.forEach(function (node) {
var visible = node.classList.contains('is-match') || !!node.querySelector('.json-viewer__node.is-match');
node.classList.toggle('is-filtered-out', !visible);
});
status.textContent = matches + (matches === 1 ? ' matching key' : ' matching keys');
});
document.body.className = 'json-viewer-page';
document.body.replaceChildren(viewer);
})();
+144
View File
@@ -0,0 +1,144 @@
/* Processes page — sovereign://processes
* Imports the shared sovereign:// base theme, then adds processes-specific
* layout for the process/tab tables, heat bars, and drill-down panel. */
@import url("sovereign://sovereign-base.css");
.note { font-size: 12px; font-weight: normal; }
code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; }
.url-sub { font-size: 11px; }
.dim { color: var(--muted); }
/* Tab navigation (shared with fips.css pattern). */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
#refresh-btn { margin-left: 12px; }
/* Process + tab tables. */
#proc-table, #tabs-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#proc-table th, #proc-table td,
#tabs-table th, #tabs-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
white-space: nowrap;
}
#proc-table th, #tabs-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
cursor: pointer;
user-select: none;
}
#proc-table th:hover, #tabs-table th:hover { color: var(--primary); }
#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#proc-table td.title-cell,
#tabs-table td.title-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
#proc-table td.url-cell,
#tabs-table td.url-cell {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
}
/* CPU values: plain text, no shaded background. Red only for >= 90%,
* otherwise the default foreground color. */
.heat {
font-weight: bold;
color: var(--fg);
}
.heat.hot { color: var(--accent); }
/* Renderer row: clickable to expand hosted tabs. */
.renderer-row { cursor: pointer; }
.renderer-row:hover { background: var(--border); }
.hosted-tabs-row td { padding-left: 24px; color: var(--muted); }
.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; }
.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; }
/* Drill-down panel. */
#drilldown {
margin-top: 24px;
border-top: 2px solid var(--primary);
padding-top: 12px;
}
#drilldown h2 { margin-bottom: 8px; }
#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; }
.sub-table {
width: 100%;
border-collapse: collapse;
margin: 6px 0 12px;
}
.sub-table th, .sub-table td {
text-align: left;
padding: 4px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.sub-table th { color: var(--muted); font-weight: bold; }
.sub-table td.mono { font-family: var(--font); word-break: break-all; }
/* Long-task timeline: a horizontal bar of vertical lines, one per
* long task, height scaled by duration. */
.timeline {
height: 80px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
overflow: hidden;
position: relative;
background: var(--bg);
}
.timeline .tl-bar {
display: inline-block;
width: 2px;
margin-right: 1px;
background: var(--accent);
vertical-align: bottom;
}
.timeline .tl-empty {
color: var(--muted);
font-size: 12px;
padding: 4px 8px;
}
.actions { margin-top: 16px; }
.actions .btn { margin-right: 8px; }
.empty { color: var(--muted); font-style: italic; }
.mono { font-family: var(--font); word-break: break-all; }
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Processes — Sovereign Browser</title>
<link rel="stylesheet" href="sovereign://processes.css">
</head>
<body>
<h1>Processes <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="processes">Processes</button>
<button class="tab-btn" data-tab="tabs">Tabs</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Processes tab -->
<div class="tab-panel active" id="tab-processes">
<p class="note">
All sovereign_browser processes: the main browser, WebKit renderer
/ network / GPU processes, and managed Tor/FIPS subprocesses.
CPU% is computed from <code>/proc</code> utime+stime deltas
(first sample is always 0). Renderer rows expand to show their
hosted tabs.
</p>
<table id="proc-table">
<thead>
<tr>
<th data-sort="pid">PID</th>
<th data-sort="name">Name</th>
<th data-sort="cpu_percent">CPU%</th>
<th data-sort="rss_kb">RSS</th>
<th data-sort="pss_kb">PSS</th>
<th data-sort="threads">Threads</th>
<th data-sort="uptime_sec">Uptime</th>
<th data-sort="state">State</th>
<th data-sort="ownership">Type</th>
<th>Tabs</th>
</tr>
</thead>
<tbody id="proc-body"></tbody>
</table>
</div>
<!-- Tabs tab -->
<div class="tab-panel" id="tab-tabs">
<p class="note">
Per-tab performance from the in-page probe. <b>CPU-busy%</b> is
the fraction of the last second spent in long (>50ms)
main-thread tasks — the direct measure of which tab is hogging
CPU. Click a row for a drill-down (long-task sources, timers,
network endpoints, heap, workers).
</p>
<table id="tabs-table">
<thead>
<tr>
<th data-sort="index">#</th>
<th data-sort="title">Title</th>
<th data-sort="cpu_busy_percent">CPU-busy%</th>
<th data-sort="fps">FPS</th>
<th data-sort="timer_count">Timers</th>
<th data-sort="net_in_flight">Net</th>
<th data-sort="heap_used_mb">Heap</th>
<th data-sort="dom_node_count">DOM</th>
<th data-sort="webprocess_pid">WPID</th>
</tr>
</thead>
<tbody id="tabs-body"></tbody>
</table>
<!-- Drill-down panel -->
<div id="drilldown" style="display:none">
<h2 id="dd-title">Tab detail</h2>
<div class="card" id="dd-summary"></div>
<h3>Long-task sources <span class="note">(top scripts by total ms)</span></h3>
<table class="sub-table" id="dd-lt-sources">
<thead><tr><th>name</th><th>total ms</th><th>count</th></tr></thead>
<tbody id="dd-lt-sources-body"></tbody>
</table>
<h3>Active timers <span class="note">(top by frequency)</span></h3>
<table class="sub-table" id="dd-timers">
<thead><tr><th>code</th><th>interval ms</th><th>count</th></tr></thead>
<tbody id="dd-timers-body"></tbody>
</table>
<h3>Network endpoints <span class="note">(top by request count)</span></h3>
<table class="sub-table" id="dd-net">
<thead><tr><th>url</th><th>count</th></tr></thead>
<tbody id="dd-net-body"></tbody>
</table>
<h3>Long-task timeline <span class="note">(last 30s)</span></h3>
<div id="dd-timeline" class="timeline"></div>
<div class="actions">
<button class="btn" id="dd-reload">Reload tab</button>
<button class="btn" id="dd-suspend">Suspend tab</button>
<button class="btn" id="dd-close">Close tab</button>
<button class="btn" id="dd-close-panel">Close panel</button>
</div>
</div>
</div>
<script src="sovereign://processes.js"></script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
/* Processes page — sovereign://processes
*
* Tabs: Processes | Tabs
*
* - Processes: /proc-level view of all sovereign_browser PIDs (main,
* WebKit renderers with their hosted tabs, WebKit network/gpu, Tor,
* FIPS). Polled from sovereign://processes/list every 1s. Sortable
* columns. Renderer rows expand to show hosted tabs.
* - Tabs: per-tab probe view (cpu_busy_percent, fps, timers, net, heap,
* DOM, WPID). Polled from sovereign://processes/tabs every 1s.
* Click a row to load the drill-down (sovereign://processes/tab_probe).
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function (resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function () {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function () { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
/* ── Formatting helpers ─────────────────────────────────────────── */
function fmtKb(kb) {
if (kb == null || kb < 0) return '—';
if (kb >= 1024 * 1024) return (kb / (1024 * 1024)).toFixed(1) + 'G';
if (kb >= 1024) return (kb / 1024).toFixed(0) + 'M';
return kb + 'K';
}
function fmtUptime(sec) {
if (sec == null || sec < 0) return '—';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = Math.floor(sec % 60);
if (d > 0) return d + 'd' + h + 'h';
if (h > 0) return h + 'h' + m + 'm';
if (m > 0) return m + 'm' + s + 's';
return s + 's';
}
function heatClass(pct) {
if (pct == null) return '';
if (pct >= 90) return 'hot';
return '';
}
function heatHtml(pct) {
if (pct == null) return '<span class="heat">—</span>';
return '<span class="heat ' + heatClass(pct) + '">' +
pct.toFixed(1) + '</span>';
}
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function (panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
}
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () { switchTab(btn.dataset.tab); });
});
/* ── Processes tab ──────────────────────────────────────────────── */
var gProcSortKey = 'cpu_percent';
var gProcSortDir = 'desc';
var gProcData = [];
var gExpandedRenderers = {}; // pid -> bool
function setProcSort(key) {
if (gProcSortKey === key) {
gProcSortDir = gProcSortDir === 'asc' ? 'desc' : 'asc';
} else {
gProcSortKey = key;
gProcSortDir = (key === 'name' || key === 'state' || key === 'ownership')
? 'asc' : 'desc';
}
renderProcTable();
}
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setProcSort(th.dataset.sort); });
});
function procSorter(a, b) {
var ka = a[gProcSortKey];
var kb = b[gProcSortKey];
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gProcSortDir === 'asc' ? cmp : -cmp;
}
function renderProcTable() {
var body = document.getElementById('proc-body');
body.innerHTML = '';
var sorted = gProcData.slice().sort(procSorter);
// Update sort indicators.
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gProcSortKey) {
th.classList.add(gProcSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (p) {
var tr = document.createElement('tr');
var isRenderer = p.ownership === 'webkit-renderer';
tr.className = isRenderer ? 'renderer-row' : '';
var tabsCount = (p.hosted_tabs || []).length;
var tabsCell;
if (isRenderer) {
tabsCell = String(tabsCount);
} else {
tabsCell = '—';
}
tr.innerHTML =
'<td>' + p.pid + '</td>' +
'<td class="title-cell">' + esc(p.name) + '</td>' +
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
'<td>' + fmtKb(p.rss_kb) + '</td>' +
'<td>' + fmtKb(p.pss_kb) + '</td>' +
'<td>' + (p.threads || 0) + '</td>' +
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
'<td>' + esc(p.state) + '</td>' +
'<td>' + esc(p.ownership) +
(p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '</td>' +
'<td>' + tabsCell + '</td>';
if (isRenderer) {
(function (pid, hosted) {
tr.addEventListener('click', function () {
gExpandedRenderers[pid] = !gExpandedRenderers[pid];
renderProcTable();
});
})(p.pid, p.hosted_tabs);
}
body.appendChild(tr);
// Expanded hosted-tabs row.
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
var sub = document.createElement('tr');
sub.className = 'hosted-tabs-row';
var html = '<td colspan="10">';
p.hosted_tabs.forEach(function (t) {
html += '<span class="ht-entry"><span class="ht-idx">#' +
t.index + '</span>' + esc(t.title || '(untitled)') +
' <span class="url-sub">' + esc(t.url) + '</span></span>';
});
html += '</td>';
sub.innerHTML = html;
body.appendChild(sub);
}
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
}
}
async function fetchProcesses() {
try {
var data = await sovereignGet('sovereign://processes/list?_t=' + Date.now());
gProcData = Array.isArray(data) ? data : [];
renderProcTable();
} catch (e) {
showMsg('Failed to load processes: ' + e.message, true);
}
}
/* ── Tabs tab ───────────────────────────────────────────────────── */
var gTabsSortKey = 'cpu_busy_percent';
var gTabsSortDir = 'desc';
var gTabsData = [];
var gSelectedTab = -1;
function setTabsSort(key) {
if (gTabsSortKey === key) {
gTabsSortDir = gTabsSortDir === 'asc' ? 'desc' : 'asc';
} else {
gTabsSortKey = key;
gTabsSortDir = (key === 'title') ? 'asc' : 'desc';
}
renderTabsTable();
}
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setTabsSort(th.dataset.sort); });
});
function tabSorter(a, b) {
var ka = a[gTabsSortKey];
var kb = b[gTabsSortKey];
// For probe fields, fall back to -1 if probe is null.
if (gTabsSortKey !== 'index' && gTabsSortKey !== 'title' &&
gTabsSortKey !== 'webprocess_pid') {
ka = (a.probe && a.probe[gTabsSortKey] != null) ? a.probe[gTabsSortKey] : -1;
kb = (b.probe && b.probe[gTabsSortKey] != null) ? b.probe[gTabsSortKey] : -1;
}
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gTabsSortDir === 'asc' ? cmp : -cmp;
}
function probeField(tab, key, fmt) {
if (!tab.probe) return '—';
var v = tab.probe[key];
if (v == null || v < 0) return '—';
return fmt ? fmt(v) : v;
}
function renderTabsTable() {
var body = document.getElementById('tabs-body');
body.innerHTML = '';
var sorted = gTabsData.slice().sort(tabSorter);
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gTabsSortKey) {
th.classList.add(gTabsSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (t) {
var tr = document.createElement('tr');
tr.className = 'tab-row' + (t.index === gSelectedTab ? ' selected' : '');
var busy = (t.probe && t.probe.cpu_busy_percent != null)
? t.probe.cpu_busy_percent : null;
var titleClass = (t.index === gSelectedTab) ? '' : 'dim';
tr.innerHTML =
'<td>' + t.index + '</td>' +
'<td class="title-cell"><span class="' + titleClass + '">' +
esc(t.title || '(untitled)') + '</span>' +
(t.is_internal ? ' (internal)' : '') +
'<br><span class="url-sub">' + esc(t.url) + '</span></td>' +
'<td>' + (t.is_internal ? '<span class="heat">—</span>'
: heatHtml(busy)) + '</td>' +
'<td>' + probeField(t, 'fps') + '</td>' +
'<td>' + probeField(t, 'timer_count') + '</td>' +
'<td>' + probeField(t, 'net_in_flight') + '</td>' +
'<td>' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '</td>' +
'<td>' + probeField(t, 'dom_node_count') + '</td>' +
'<td>' + (t.webprocess_pid || 0) + '</td>';
(function (idx) {
tr.addEventListener('click', function () { openDrilldown(idx); });
})(t.index);
body.appendChild(tr);
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="9" class="empty">No tabs.</td></tr>';
}
}
async function fetchTabs() {
try {
var data = await sovereignGet('sovereign://processes/tabs?_t=' + Date.now());
gTabsData = Array.isArray(data) ? data : [];
renderTabsTable();
// If a tab is selected, refresh its drill-down too.
if (gSelectedTab >= 0) refreshDrilldown();
} catch (e) {
showMsg('Failed to load tabs: ' + e.message, true);
}
}
/* ── Drill-down ─────────────────────────────────────────────────── */
async function openDrilldown(idx) {
gSelectedTab = idx;
renderTabsTable();
document.getElementById('drilldown').style.display = 'block';
await refreshDrilldown();
}
async function refreshDrilldown() {
if (gSelectedTab < 0) return;
try {
var d = await sovereignGet('sovereign://processes/tab_probe?index=' +
gSelectedTab);
renderDrilldown(d);
} catch (e) {
document.getElementById('dd-title').textContent = 'Tab #' + gSelectedTab;
document.getElementById('dd-summary').innerHTML =
'<p class="err-text">Failed to load drill-down: ' + esc(e.message) + '</p>';
}
}
function renderDrilldown(d) {
document.getElementById('dd-title').textContent =
'Tab #' + d.index + ' — ' + (d.title || '(untitled)');
var p = d.probe || {};
var rows = [
['URL', esc(d.url)],
['WebProcess PID', d.webprocess_pid || 0],
['CPU-busy %', p.cpu_busy_percent != null ? heatHtml(p.cpu_busy_percent) : '—'],
['FPS', p.fps != null ? p.fps : '—'],
['Active timers', p.timer_count != null ? p.timer_count : '—'],
['Net in-flight', p.net_in_flight != null ? p.net_in_flight : '—'],
['Net reqs (last sec)', p.net_requests_last_sec != null ? p.net_requests_last_sec : '—'],
['Heap used', p.heap_used_mb >= 0 ? p.heap_used_mb + 'M' : '—'],
['Event listeners', p.event_listener_count != null ? p.event_listener_count : '—'],
['Workers', p.worker_count != null ? p.worker_count : '—'],
['DOM nodes', p.dom_node_count >= 0 ? p.dom_node_count : '—']
];
var html = '';
rows.forEach(function (r) {
html += '<div class="info-row"><span>' + r[0] + '</span><span>' +
r[1] + '</span></div>';
});
document.getElementById('dd-summary').innerHTML = html;
// Long-task sources.
var ltBody = document.getElementById('dd-lt-sources-body');
ltBody.innerHTML = '';
(p.long_task_top_sources || []).forEach(function (s) {
ltBody.innerHTML += '<tr><td class="mono">' + esc(s.name) + '</td><td>' +
s.total_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) {
ltBody.innerHTML = '<tr><td colspan="3" class="empty">No long tasks.</td></tr>';
}
// Timers.
var tmBody = document.getElementById('dd-timers-body');
tmBody.innerHTML = '';
(p.timer_top || []).forEach(function (s) {
tmBody.innerHTML += '<tr><td class="mono">' + esc(s.code) + '</td><td>' +
s.interval_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.timer_top || p.timer_top.length === 0) {
tmBody.innerHTML = '<tr><td colspan="3" class="empty">No active timers.</td></tr>';
}
// Network endpoints.
var netBody = document.getElementById('dd-net-body');
netBody.innerHTML = '';
(p.net_top_endpoints || []).forEach(function (s) {
netBody.innerHTML += '<tr><td class="mono">' + esc(s.url) + '</td><td>' +
s.count + '</td></tr>';
});
if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) {
netBody.innerHTML = '<tr><td colspan="2" class="empty">No network requests.</td></tr>';
}
// Timeline.
var tl = document.getElementById('dd-timeline');
tl.innerHTML = '';
var timeline = p.long_task_timeline || [];
if (timeline.length === 0) {
tl.innerHTML = '<div class="tl-empty">No long tasks in the last 30s.</div>';
} else {
var maxDur = 1;
timeline.forEach(function (r) { if (r.duration > maxDur) maxDur = r.duration; });
timeline.forEach(function (r) {
var h = Math.max(2, Math.round((r.duration / maxDur) * 72));
var bar = document.createElement('span');
bar.className = 'tl-bar';
bar.style.height = h + 'px';
bar.title = r.duration + 'ms — ' + (r.name || 'unknown');
tl.appendChild(bar);
});
}
}
/* ── Drill-down actions ─────────────────────────────────────────── */
document.getElementById('dd-reload').addEventListener('click', function () {
if (gSelectedTab < 0) return;
// Trigger reload via the bridge — there's no dedicated endpoint, so
// we use the agent eval path indirectly. Simplest: navigate the tab
// by opening its URL again via sovereign://processes/tab_action.
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=reload').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-suspend').addEventListener('click', function () {
if (gSelectedTab < 0) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=suspend').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-close').addEventListener('click', function () {
if (gSelectedTab < 0) return;
if (!confirm('Close tab #' + gSelectedTab + '?')) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=close').then(function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
fetchTabs();
});
});
document.getElementById('dd-close-panel').addEventListener('click', function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
renderTabsTable();
});
/* ── Refresh button + polling ───────────────────────────────────── */
document.getElementById('refresh-btn').addEventListener('click', function () {
fetchProcesses();
fetchTabs();
});
function poll() {
fetchProcesses();
fetchTabs();
}
poll();
setInterval(poll, 1000);
+8
View File
@@ -0,0 +1,8 @@
/* Profile page — sovereign://profile
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");
.profile-picture {
max-width: 120px; max-height: 120px; border-radius: 8px;
float: right; margin-left: 16px;
}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Profile</title>
<link rel="stylesheet" href="sovereign://profile.css">
</head>
<body>
<h1>Profile</h1>
<div id="profile-body">
<p class="note">Loading profile…</p>
</div>
<p class="note">
This page shows data cached in the local SQLite database from the
bootstrap relay fetch. If fields show
<span class="missing">(not found)</span>, the relay fetch hasn't
completed or the user has no events of that kind on the bootstrap relays.
</p>
<script src="sovereign://profile.js"></script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
/* Profile page — sovereign://profile
*
* Fetches profile data (identity, kind 0 metadata, kind 3 contacts,
* kind 10002 relay list, npub, QR code URL) from sovereign://profile/data
* (JSON) and renders it.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function infoRow(label, value) {
return '<div class="info-row"><span>' + esc(label) + '</span>' +
'<span class="info">' + (value == null || value === '' ?
'<span class="missing">(not found)</span>' : esc(value)) +
'</span></div>';
}
function renderProfile(d) {
var c = document.getElementById('profile-body');
if (!d || !d.pubkey) {
c.innerHTML = '<p class="note">No identity loaded. Log in to see your profile.</p>';
return;
}
var html = '';
/* Profile picture (kind 0). */
if (d.picture) {
html += '<img class="profile-picture" src="' + esc(d.picture) +
'" alt="Profile picture">';
}
/* QR code for the npub URI. */
if (d.qr_url) {
html += '<div class="qr"><img src="' + esc(d.qr_url) +
'" alt="npub QR code"></div>';
}
/* Identity section. */
html += '<h2>Identity</h2>';
html += infoRow('Pubkey', d.pubkey_short);
html += infoRow('npub URI', d.npub);
html += infoRow('Method', d.method);
html += infoRow('Signing', d.signing);
/* Profile (kind 0). */
html += '<h2>Profile (kind 0)</h2>';
if (!d.kind0_present) {
html += '<p class="missing">(no kind 0 event in database)</p>';
}
html += infoRow('Name', d.name);
html += infoRow('Display Name', d.display_name);
if (d.about) {
html += '<div class="info-row"><span>About</span>' +
'<span class="info" style="max-width:400px;">' + esc(d.about) +
'</span></div>';
}
html += infoRow('Website', d.website);
html += infoRow('Lightning (lud16)', d.lud16);
html += infoRow('Last updated', d.kind0_created);
html += infoRow('Events in DB', d.kind0_count);
/* Contacts (kind 3). */
html += '<h2>Contacts (kind 3)</h2>';
if (!d.kind3_present) {
html += '<p class="missing">(no kind 3 event in database)</p>';
}
html += infoRow('Following', (d.contact_count != null ?
d.contact_count + ' pubkey(s)' : ''));
html += infoRow('Last updated', d.kind3_created);
html += infoRow('Events in DB', d.kind3_count);
/* Relay list (kind 10002). */
html += '<h2>Relay List (kind 10002)</h2>';
html += infoRow('Events in DB', d.kind10002_count);
c.innerHTML = html;
}
sovereignGet('sovereign://profile/data?_=' + Date.now())
.then(function(d) { renderProfile(d); })
.catch(function(e) {
document.getElementById('profile-body').innerHTML =
'<p class="note">Failed to load profile: ' + esc(e.message) + '</p>';
});
+3
View File
@@ -0,0 +1,3 @@
/* Settings page — sovereign://settings
* Imports the shared sovereign:// base theme. */
@import url("sovereign://sovereign-base.css");
+162
View File
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings</title>
<link rel="stylesheet" href="sovereign://settings.css">
</head>
<body>
<h1>Settings</h1>
<h2>Appearance</h2>
<div class="setting">
<div><div class="setting-name">Dark mode (Night)</div></div>
<div class="toggle" data-feature="theme_dark"
onclick="toggle('theme_dark')"></div>
</div>
<h2>Tabs</h2>
<div class="setting">
<div><div class="setting-name">Restore session on startup</div></div>
<div class="toggle" data-feature="restore_session"
onclick="toggle('restore_session')"></div>
</div>
<div class="field">
<div><div class="setting-name">New tab URL</div></div>
<div><input type="text" id="new_tab_url">
<button class="save-btn" onclick="save('new_tab_url')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Tab bar position</div></div>
<div><select id="tab_bar_position">
<option value="top">Top</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
<button class="save-btn" onclick="save('tab_bar_position')">Save</button></div>
</div>
<div class="setting">
<div><div class="setting-name">Show tab close buttons</div></div>
<div class="toggle" data-feature="show_tab_close_buttons"
onclick="toggle('show_tab_close_buttons')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Middle-click to close tab</div></div>
<div class="toggle" data-feature="middle_click_close"
onclick="toggle('middle_click_close')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Ctrl+Tab to switch tabs</div></div>
<div class="toggle" data-feature="ctrl_tab_switch"
onclick="toggle('ctrl_tab_switch')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Allow drag to reorder tabs</div></div>
<div class="toggle" data-feature="tab_drag_reorder"
onclick="toggle('tab_drag_reorder')"></div>
</div>
<div class="field">
<div><div class="setting-name">Maximum tabs</div></div>
<div><input type="number" id="max_tabs" min="1" max="999">
<button class="save-btn" onclick="save('max_tabs')">Save</button></div>
</div>
<h2>Keyboard Shortcuts</h2>
<div id="shortcuts-section"></div>
<div class="field">
<div><div class="setting-name">Reset all shortcuts</div></div>
<div><button class="save-btn" id="sc-reset-all">Reset All</button></div>
</div>
<h2>Agent Server</h2>
<div class="setting">
<div><div class="setting-name">Enable agent server</div></div>
<div class="toggle" data-feature="agent_server_enabled"
onclick="toggle('agent_server_enabled')"></div>
</div>
<div class="field">
<div><div class="setting-name">Agent server port</div></div>
<div><input type="number" id="agent_server_port" min="1" max="65535">
<button class="save-btn" onclick="save('agent_server_port')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Allowed origins</div></div>
<div><input type="text" id="agent_allowed_origins">
<button class="save-btn" onclick="save('agent_allowed_origins')">Save</button></div>
</div>
<div class="field">
<div><div class="setting-name">Login timeout (ms)</div></div>
<div><input type="number" id="agent_login_timeout_ms" min="0" max="300000">
<button class="save-btn" onclick="save('agent_login_timeout_ms')">Save</button></div>
</div>
<h2>Bootstrap Relays</h2>
<div class="field">
<div><div class="setting-name">Relay URLs</div></div>
<div><textarea id="bootstrap_relays" rows="4" cols="40"></textarea>
<button class="save-btn" onclick="save('bootstrap_relays')">Save</button></div>
</div>
<h2>Search</h2>
<div class="field">
<div><div class="setting-name">Search engine</div></div>
<div><select id="search_engine"></select>
<button class="save-btn" onclick="save('search_engine')">Save</button></div>
</div>
<h2>Network</h2>
<div class="field">
<div><div class="setting-name">FIPS Mesh Network</div>
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
</div>
<h2>Security</h2>
<div class="setting">
<div><div class="setting-name">Developer Tools (Inspector)</div></div>
<div class="toggle" data-feature="dev_extras"
onclick="toggle('dev_extras')"></div>
</div>
<div class="setting">
<div><div class="setting-name">File Access from file://</div></div>
<div class="toggle" data-feature="file_access"
onclick="toggle('file_access')"></div>
</div>
<div class="setting">
<div><div class="setting-name">Universal Access from file://</div></div>
<div class="toggle" data-feature="universal_access"
onclick="toggle('universal_access')"></div>
</div>
<div class="setting">
<div><div class="setting-name">CORS Enforcement</div></div>
<div class="toggle off" style="opacity:0.5"></div>
</div>
<div class="setting">
<div><div class="setting-name">TLS Certificate Check</div></div>
<div class="toggle off" style="opacity:0.5"></div>
</div>
<div id="status" class="status"></div>
<script src="sovereign://settings.js"></script>
</body>
</html>
+326
View File
@@ -0,0 +1,326 @@
/* Settings page — sovereign://settings
*
* Fetches all settings values from sovereign://settings/config (JSON) on
* page load and populates the DOM. Toggles, saves, and shortcut capture
* use the existing sovereign://settings/set endpoint.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showStatus(cls, msg) {
var s = document.getElementById('status');
s.className = 'status ' + cls + ' show';
s.textContent = msg;
}
/* Set a toggle element's on/off class. */
function setToggleClass(el, on) {
el.classList.remove('on', 'off');
el.classList.add(on ? 'on' : 'off');
}
/* Apply the settings/config JSON to the DOM. */
function applyConfig(d) {
/* Toggles — each has a data-feature attribute identifying the key. */
var toggleMap = {
theme_dark: d.theme_dark,
restore_session: d.restore_session,
show_tab_close_buttons: d.show_tab_close_buttons,
middle_click_close: d.middle_click_close,
ctrl_tab_switch: d.ctrl_tab_switch,
tab_drag_reorder: d.tab_drag_reorder,
agent_server_enabled: d.agent_server_enabled,
dev_extras: d.dev_extras,
file_access: d.file_access,
universal_access: d.universal_access
};
document.querySelectorAll('.toggle[data-feature]').forEach(function(el) {
var key = el.getAttribute('data-feature');
if (key === 'theme_dark') el.id = 'toggle_theme_dark';
setToggleClass(el, !!toggleMap[key]);
});
/* Text/number inputs. */
if (d.new_tab_url != null)
document.getElementById('new_tab_url').value = d.new_tab_url;
if (d.max_tabs != null)
document.getElementById('max_tabs').value = d.max_tabs;
if (d.agent_server_port != null)
document.getElementById('agent_server_port').value = d.agent_server_port;
if (d.agent_allowed_origins != null)
document.getElementById('agent_allowed_origins').value = d.agent_allowed_origins;
if (d.agent_login_timeout_ms != null)
document.getElementById('agent_login_timeout_ms').value = d.agent_login_timeout_ms;
if (d.bootstrap_relays != null)
document.getElementById('bootstrap_relays').value = d.bootstrap_relays;
/* Tab bar position select. */
if (d.tab_bar_position != null) {
var sel = document.getElementById('tab_bar_position');
sel.value = d.tab_bar_position;
}
/* Search engine select. */
var se = document.getElementById('search_engine');
se.innerHTML = '';
(d.search_engines || []).forEach(function(eng) {
var opt = document.createElement('option');
opt.value = eng.id;
opt.textContent = eng.name;
if (eng.id === d.search_engine) opt.selected = true;
se.appendChild(opt);
});
/* Keyboard shortcuts. */
renderShortcuts(d.shortcuts || []);
}
/* ── Keyboard shortcut capture ──────────────────────────────────── */
var capturingAction = null;
var capturingDisplay = null;
function jsKeyToGdk(e) {
var k = e.key;
if (k.length === 1) {
var c = k.toLowerCase();
if (c >= 'a' && c <= 'z') return c;
if (c >= '0' && c <= '9') return c;
if (k === ',') return 'comma';
if (k === ' ') return 'space';
if (k === '.') return 'period';
if (k === '/') return 'slash';
if (k === '-') return 'minus';
if (k === '=') return 'equal';
if (k === '+') return 'plus';
}
var special = {
'Tab':'Tab','Enter':'Return','Escape':'Escape',
'Backspace':'BackSpace','Delete':'Delete',
'Home':'Home','End':'End',
'PageUp':'Page_Up','PageDown':'Page_Down',
'ArrowLeft':'Left','ArrowRight':'Right',
'ArrowUp':'Up','ArrowDown':'Down',
'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6',
'F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12'
};
if (special[k]) return special[k];
return null;
}
function buildAccel(e) {
var parts = [];
if (e.ctrlKey) parts.push('<Control>');
if (e.altKey) parts.push('<Alt>');
if (e.shiftKey) parts.push('<Shift>');
if (e.metaKey) parts.push('<Super>');
var gdkKey = jsKeyToGdk(e);
if (!gdkKey) return null;
parts.push(gdkKey);
return parts.join('');
}
function displayAccel(accel) {
return accel.replace(/<Control>/g,'Ctrl+')
.replace(/<Alt>/g,'Alt+')
.replace(/<Shift>/g,'Shift+')
.replace(/<Super>/g,'Super+');
}
function renderShortcuts(shortcuts) {
var c = document.getElementById('shortcuts-section');
var html = '';
shortcuts.forEach(function(sc) {
var accel = sc.accel || '';
html += '<div class="field shortcut-row" data-action="' + esc(sc.id) +
'" data-accel="' + esc(accel) + '">';
html += '<div><div class="setting-name">' + esc(sc.label) + '</div></div>';
html += '<div><span class="shortcut-display" id="sc_' + esc(sc.id) + '">' +
esc(displayAccel(accel)) + '</span>';
html += ' <button class="save-btn sc-change" data-action="' + esc(sc.id) +
'">Change</button>';
html += ' <button class="save-btn sc-reset" data-action="' + esc(sc.id) +
'">Reset</button></div>';
html += '</div>';
});
c.innerHTML = html;
bindShortcutButtons();
checkDuplicates();
}
function checkDuplicates() {
var rows = document.querySelectorAll('.shortcut-row');
var seen = {};
rows.forEach(function(r) {
r.classList.remove('duplicate');
var a = r.getAttribute('data-accel');
if (a) {
if (seen[a]) seen[a].push(r);
else seen[a] = [r];
}
});
Object.keys(seen).forEach(function(a) {
if (seen[a].length > 1) {
seen[a].forEach(function(r) { r.classList.add('duplicate'); });
}
});
}
function bindShortcutButtons() {
document.querySelectorAll('.sc-change').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.getAttribute('data-action');
var disp = document.getElementById('sc_' + action);
if (!disp) return;
if (capturingDisplay) capturingDisplay.classList.remove('capturing');
capturingAction = action;
capturingDisplay = disp;
disp.setAttribute('data-prev', disp.textContent);
disp.classList.add('capturing');
disp.textContent = 'Press keys…';
});
});
document.querySelectorAll('.sc-reset').forEach(function(btn) {
btn.addEventListener('click', function() {
resetShortcut(btn.getAttribute('data-action'));
});
});
var ra = document.getElementById('sc-reset-all');
if (ra) ra.onclick = resetAllShortcuts;
}
function commitShortcut(action, accel) {
sovereignGet('sovereign://settings/set?key=shortcut.' + action +
'&value=' + encodeURIComponent(accel))
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', action + ' = ' + displayAccel(accel));
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
function resetShortcut(action) {
sovereignGet('sovereign://settings/set?key=shortcut.reset&action=' + action)
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', action + ' reset to default');
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
function resetAllShortcuts() {
sovereignGet('sovereign://settings/set?key=shortcut.reset_all')
.then(function(d) {
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', 'All shortcuts reset to defaults');
setTimeout(function() { location.reload(); }, 400);
}
})
.catch(function(e) { showStatus('err', 'Error: ' + e.message); });
}
document.addEventListener('keydown', function(e) {
if (!capturingAction) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
capturingDisplay.classList.remove('capturing');
capturingDisplay.textContent =
capturingDisplay.getAttribute('data-prev') || '';
capturingAction = null;
capturingDisplay = null;
showStatus('ok', 'Cancelled');
return;
}
var bareMod = ['Control','Shift','Alt','Meta'].indexOf(e.key) >= 0;
if (bareMod) return;
var accel = buildAccel(e);
if (!accel) return;
capturingDisplay.classList.remove('capturing');
capturingDisplay.textContent = displayAccel(accel);
var action = capturingAction;
capturingAction = null;
capturingDisplay = null;
commitShortcut(action, accel);
}, true);
/* ── Toggle / save actions ──────────────────────────────────────── */
function toggle(feature) {
if (feature === 'theme_dark') {
var h = document.documentElement;
var isDark = h.classList.contains('dark');
if (isDark) h.classList.remove('dark');
else h.classList.add('dark');
var btn = document.getElementById('toggle_theme_dark');
if (btn) setToggleClass(btn, !isDark);
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?feature=' + feature, true);
x.send();
showStatus('ok', 'theme_dark: ' + (isDark ? 'OFF' : 'ON'));
return;
}
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?feature=' + feature, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try {
var d = JSON.parse(x.responseText);
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF'));
setTimeout(function() { location.reload(); }, 300);
}
} catch (e) { showStatus('err', 'Error: ' + e.message); }
};
x.send();
}
function save(key) {
var el = document.getElementById(key);
var val = el ? el.value : '';
var x = new XMLHttpRequest();
x.open('GET', 'sovereign://settings/set?key=' + encodeURIComponent(key) +
'&value=' + encodeURIComponent(val), true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try {
var d = JSON.parse(x.responseText);
if (d.error) showStatus('err', d.message);
else {
showStatus('ok', d.key + ' = ' + d.value);
if (d.reload) setTimeout(function() { location.reload(); }, 300);
}
} catch (e) { showStatus('err', 'Error: ' + e.message); }
};
x.send();
}
/* ── Init ───────────────────────────────────────────────────────── */
sovereignGet('sovereign://settings/config?_=' + Date.now())
.then(function(d) { applyConfig(d); })
.catch(function(e) {
showStatus('err', 'Failed to load settings: ' + e.message);
});
+149
View File
@@ -0,0 +1,149 @@
/* Shared sovereign:// page CSS (theme variables, base elements, buttons).
* Mirrors sovereign_page_css() in src/nostr_bridge.c so the embedded
* file-based pages stay consistent with the legacy C-string pages.
* Linked by settings.html, bookmarks.html, profile.html, agents/config.html. */
:root {
--font: monospace;
--primary: #000000;
--secondary: #ffffff;
--accent: #ff0000;
--muted: #dddddd;
--border: var(--muted);
--radius: 5px;
--bg: var(--secondary);
--fg: var(--primary);
--img-gray: 100%;
--img-gray-hover: 20%;
color-scheme: light;
}
html.dark {
--primary: #ffffff;
--secondary: #000000;
--accent: #ff0000;
--muted: #777777;
--border: var(--muted);
color-scheme: dark;
}
* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }
html { transition: background-color 0.2s ease, color 0.2s ease; }
body {
color: var(--fg); background: var(--bg);
max-width: 800px; margin: 40px auto; padding: 20px;
line-height: 1.5;
}
h1 {
color: var(--primary); text-align: center;
border-bottom: 2px solid var(--primary);
padding-bottom: 8px; margin-bottom: 20px;
}
h2 {
color: var(--primary); margin-top: 30px; margin-bottom: 10px;
border-bottom: 1px solid var(--border); padding-bottom: 4px;
}
h3 { color: var(--primary); margin-top: 20px; margin-bottom: 8px; }
a { color: var(--accent); text-decoration: none; transition: 0.2s; }
a:hover { text-decoration: underline; }
img { filter: grayscale(var(--img-gray)); transition: filter 0.2s; }
img:hover { filter: grayscale(var(--img-gray-hover)); }
.note { color: var(--muted); font-size: 12px; margin: 10px 0; }
.info { color: var(--muted); font-size: 12px; word-break: break-all; }
.missing { color: var(--muted); font-style: italic; }
.info-row {
display: flex; justify-content: space-between;
gap: 20px; padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.info-row span:first-child { font-weight: bold; flex-shrink: 0; }
.setting, .field {
display: flex; justify-content: space-between;
align-items: center; gap: 20px; padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.setting-name { font-weight: bold; }
.setting-desc { color: var(--muted); font-size: 12px; margin-top: 4px; }
.toggle {
position: relative; width: 50px; height: 24px;
background: var(--muted); border-radius: 12px; cursor: pointer;
transition: background 0.2s; flex-shrink: 0;
}
.toggle.on { background: var(--primary); }
.toggle.off { background: var(--muted); }
.toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px; border-radius: 50%; background: var(--secondary);
transition: transform 0.2s;
}
.toggle.on::after { transform: translateX(26px); }
input, select, textarea {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); font-size: 13px;
min-width: 200px;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn, button.btn {
background: var(--bg); color: var(--primary);
-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, 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); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
.status {
padding: 8px; margin: 10px 0; border-radius: var(--radius);
display: none; border: 1px solid var(--border);
}
.status.show { display: block; }
.status.ok { border-color: var(--primary); color: var(--primary); }
.status.err { border-color: var(--accent); color: var(--accent); }
.shortcut-display {
display: inline-block; min-width: 120px;
padding: 4px 10px; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); font-family: var(--font); font-size: 13px;
text-align: center;
}
.shortcut-display.capturing {
border-color: var(--accent);
animation: pulse 1s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
.shortcut-row.duplicate .shortcut-display { border-color: var(--accent); }
.sc-change, .sc-reset { min-width: 70px; }
.qr { text-align: center; margin: 12px 0; }
.qr img {
image-rendering: pixelated; border: 4px solid var(--secondary);
border-radius: 4px;
}
.theme-toggle {
position: fixed; top: 20px; right: 20px;
display: flex; align-items: center; gap: 8px; cursor: pointer;
text-decoration: none; user-select: none;
font-size: 12px; color: var(--muted); user-select: none;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 4px 10px; background: var(--bg); z-index: 10;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.bm {
display: flex; justify-content: space-between; align-items: center;
gap: 20px; padding: 8px 0; border-bottom: 1px solid var(--border);
}
.bm-url { color: var(--accent); text-decoration: none; word-break: break-all; }
.bm-url:hover { text-decoration: underline; }
.bm-title { color: var(--primary); font-weight: bold; }
.bm-meta { color: var(--muted); font-size: 11px; }
.actions { display: flex; gap: 8px; flex-shrink: 0; }
.form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.empty { color: var(--muted); font-style: italic; padding: 8px 0; }
.dir-header {
display: flex; justify-content: space-between;
align-items: center; gap: 20px;
}
+6
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long