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:
@@ -0,0 +1,3 @@
|
|||||||
|
/target/
|
||||||
|
*.log
|
||||||
|
*.tar.gz
|
||||||
Generated
+4110
File diff suppressed because it is too large
Load Diff
+71
@@ -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"
|
||||||
Executable
+407
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: >k::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(¬ebook, 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
@@ -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: >k::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: >k::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(¬ebook);
|
||||||
|
|
||||||
|
tab_manager::tab_manager_init();
|
||||||
|
let ctx = build_context_for_current_user();
|
||||||
|
tab_manager::tab_manager_set_main_window(&window, ¬ebook);
|
||||||
|
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(¬ebook, &ctx, Some(url));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab_manager::tab_manager_new_tab(¬ebook, &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(¬ebook, &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
@@ -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: >k::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)");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
|
}
|
||||||
@@ -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('&', &)
|
||||||
|
.replace('<', <)
|
||||||
|
.replace('>', >)
|
||||||
|
.replace('"', ")
|
||||||
|
.replace("'", &apos)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 ‘+ New Chat’ 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">↻</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">↻</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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
/* Agent config page — sovereign://agents
|
||||||
|
* Imports the shared sovereign:// base theme. */
|
||||||
|
@import url("sovereign://sovereign-base.css");
|
||||||
@@ -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>
|
||||||
@@ -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() {});
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>';
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* Settings page — sovereign://settings
|
||||||
|
* Imports the shared sovereign:// base theme. */
|
||||||
|
@import url("sovereign://sovereign-base.css");
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user