Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d91a186d4 | ||
|
|
6a8e51c812 | ||
|
|
12f8b45015 | ||
|
|
b90ea0bb11 | ||
|
|
5b949ec034 | ||
|
|
43ee97bc38 | ||
|
|
3ac4d4f9dc | ||
|
|
b111fffeaa | ||
|
|
7ca05baff1 |
@@ -96,6 +96,58 @@ Working browser with a broad feature set:
|
||||
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
|
||||
friction report from the POC phase.
|
||||
|
||||
## Install
|
||||
|
||||
One-command install on Debian 13 (trixie) or similar (x86_64, WebKitGTK 4.1):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.laantungir.net/laantungir/sovereign_browser/raw/branch/master/install.sh | bash
|
||||
```
|
||||
|
||||
This downloads and runs [`install.sh`](install.sh), which:
|
||||
|
||||
1. Installs runtime dependencies via `apt` (WebKitGTK 4.1, libsoup, curl, etc.)
|
||||
2. Installs **Tor** via `apt` (used for `.onion` routing)
|
||||
3. Builds and installs **FIPS** from source with `CAP_NET_ADMIN` (used for `.fips` mesh routing)
|
||||
4. Downloads the latest prebuilt `sovereign_browser` binary from the [Gitea releases](https://git.laantungir.net/laantungir/sovereign_browser/releases) (falls back to a source build if no binary is available)
|
||||
5. Installs a `sovereign-browser` wrapper command to `/usr/local/bin`
|
||||
|
||||
After install, start the browser with:
|
||||
|
||||
```bash
|
||||
sovereign-browser start --login-method generate
|
||||
```
|
||||
|
||||
### Install options
|
||||
|
||||
```bash
|
||||
# Non-root install to a user-writable prefix
|
||||
curl -fsSL <url> | INSTALL_PREFIX=$HOME/.local bash -s -- --yes
|
||||
|
||||
# Force a source build instead of downloading the prebuilt binary
|
||||
curl -fsSL <url> | bash -s -- --build-from-source
|
||||
|
||||
# Review the script before running it
|
||||
curl -fsSL <url> -o install.sh
|
||||
less install.sh
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
Run `bash install.sh --help` for the full option list.
|
||||
|
||||
### What the install requires
|
||||
|
||||
| Requirement | Why |
|
||||
|---|---|
|
||||
| Debian 13+ / Ubuntu 24.04+ (WebKitGTK 4.1) | The browser is dynamically linked against `libwebkit2gtk-4.1` |
|
||||
| x86_64 | Prebuilt binary is x86-64; arm64 needs `--build-from-source` |
|
||||
| `sudo` / root | For `apt install`, `setcap` on the FIPS binary, and writing to `/usr/local` |
|
||||
| Internet access | To download the binary, apt packages, and the FIPS source |
|
||||
|
||||
Tor and FIPS are **installed by default** (not optional). If either is absent at runtime the browser degrades gracefully — clearnet still works, `.onion`/`.fips` show an error page — but the installer sets them up so everything works out of the box.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
|
||||
|
||||
+201
-45
@@ -5,7 +5,8 @@ set -euo pipefail
|
||||
#
|
||||
# Installs:
|
||||
# * apt runtime deps (WebKitGTK 4.1, libsoup-3, tor, build toolchain)
|
||||
# * FIPS (built from source from https://github.com/jmcorgan/fips) with CAP_NET_ADMIN
|
||||
# * FIPS prebuilt binary from GitHub releases (source-build fallback via
|
||||
# --build-fips-from-source) with CAP_NET_ADMIN
|
||||
# * sovereign_browser prebuilt binary from Gitea releases (source-build fallback)
|
||||
# * a `sovereign-browser` wrapper that detaches the GUI
|
||||
#
|
||||
@@ -20,18 +21,22 @@ set -euo pipefail
|
||||
|
||||
GITEA_API="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
|
||||
GITEA_DOWNLOAD_BASE="https://git.laantungir.net/laantungir/sovereign_browser/releases/download"
|
||||
FIPS_REPO="https://github.com/jmcorgan/fips.git"
|
||||
SB_REPO="https://git.laantungir.net/laantungir/sovereign_browser.git"
|
||||
FIPS_GITHUB_API="https://api.github.com/repos/jmcorgan/fips/releases/latest"
|
||||
FIPS_REPO="https://github.com/jmcorgan/fips.git"
|
||||
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}"
|
||||
BIN_NAME="sovereign_browser"
|
||||
|
||||
# Runtime apt packages (non-dev). git/build-essential/pkg-config are included
|
||||
# because they're needed for the FIPS source build and the source-build fallback.
|
||||
APT_PACKAGES=(
|
||||
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode0 libsqlite3-0
|
||||
libsecp256k1-1 libssl3 libcurl4 zlib1g
|
||||
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode4 libsqlite3-0
|
||||
libsecp256k1-2 libssl3t64 libcurl4t64 zlib1g
|
||||
tor git build-essential pkg-config
|
||||
libcap2-bin
|
||||
)
|
||||
# libclang-dev is only needed when building FIPS from source (bindgen).
|
||||
APT_PACKAGES_FIPS_SOURCE=(libclang-dev)
|
||||
|
||||
# --- Output helpers ------------------------------------------------------
|
||||
|
||||
@@ -75,21 +80,27 @@ Usage: curl -fsSL <url> | bash
|
||||
or: ./install.sh [options]
|
||||
|
||||
Options:
|
||||
--build-from-source Build from source instead of downloading prebuilt binary
|
||||
--prefix <dir> Install prefix (default: /usr/local)
|
||||
--yes Skip confirmation prompts
|
||||
-h, --help Show this help
|
||||
--build-from-source Build sovereign_browser from source instead of
|
||||
downloading the prebuilt binary
|
||||
--build-fips-from-source Build FIPS from source (cargo) instead of
|
||||
downloading the prebuilt binary. Slower; needs
|
||||
libclang-dev (auto-installed).
|
||||
--prefix <dir> Install prefix (default: /usr/local)
|
||||
--yes Skip confirmation prompts
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Args ----------------------------------------------------------------
|
||||
|
||||
BUILD_FROM_SOURCE=false
|
||||
BUILD_FIPS_FROM_SOURCE=false
|
||||
ASSUME_YES=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--build-from-source) BUILD_FROM_SOURCE=true; shift ;;
|
||||
--build-fips-from-source) BUILD_FIPS_FROM_SOURCE=true; shift ;;
|
||||
--prefix) INSTALL_PREFIX="${2:-}"; shift 2 ;;
|
||||
--yes|-y) ASSUME_YES=true; shift ;;
|
||||
-h|--help) show_usage; exit 0 ;;
|
||||
@@ -163,12 +174,131 @@ install_deps() {
|
||||
}
|
||||
|
||||
# --- FIPS ----------------------------------------------------------------
|
||||
#
|
||||
# FIPS is a Rust project (https://github.com/jmcorgan/fips). It publishes
|
||||
# prebuilt release tarballs on GitHub Releases containing the `fips` binary
|
||||
# plus tools (fipsctl, fipstop) and systemd units. We download the prebuilt
|
||||
# x86_64 Linux tarball by default — much faster than a cargo build.
|
||||
#
|
||||
# With --build-fips-from-source, we instead clone the repo and run
|
||||
# `cargo build --release` (requires Rust 1.94.1+ via rustup, and libclang-dev
|
||||
# for the rustables/bindgen nftables bindings).
|
||||
|
||||
# Download the prebuilt FIPS tarball from GitHub Releases and extract the
|
||||
# `fips` binary. Outputs a stable binary path on stdout (the file is copied
|
||||
# to a caller-managed temp file so it survives after this function returns).
|
||||
# Returns non-zero on failure so the caller can fall back to a source build.
|
||||
download_fips_prebuilt() {
|
||||
print_info "Querying GitHub for latest FIPS release..."
|
||||
local api_json tag asset_url
|
||||
if ! api_json="$(curl -fsSL "$FIPS_GITHUB_API" 2>/dev/null)"; then
|
||||
print_warning "Could not reach FIPS GitHub releases API."
|
||||
return 1
|
||||
fi
|
||||
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
print_warning "Could not parse tag_name from FIPS GitHub release JSON."
|
||||
return 1
|
||||
fi
|
||||
# Find the linux-x86_64 tarball asset URL.
|
||||
asset_url="$(printf '%s' "$api_json" | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*linux-x86_64\.tar\.gz"' | head -1 | sed -E 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
|
||||
if [[ -z "$asset_url" ]]; then
|
||||
print_warning "No linux-x86_64 tarball found in FIPS release $tag."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use a temp dir for download + extraction. We do NOT use a RETURN trap
|
||||
# here because the caller needs to copy the binary out after this
|
||||
# function returns — a RETURN trap would delete the file first. Instead
|
||||
# we copy the binary to a stable temp file and clean up the extraction
|
||||
# dir ourselves before returning.
|
||||
local tmp fips_bin stable
|
||||
tmp="$(mktemp -d -t fips_dl.XXXXXX)"
|
||||
print_info "Downloading FIPS prebuilt binary: $asset_url"
|
||||
if ! curl -fsSL -o "$tmp/fips.tar.gz" "$asset_url"; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball download failed."
|
||||
return 1
|
||||
fi
|
||||
if ! tar -xzf "$tmp/fips.tar.gz" -C "$tmp" 2>/dev/null; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball extraction failed."
|
||||
return 1
|
||||
fi
|
||||
# The tarball extracts to fips-<ver>-linux-x86_64/fips
|
||||
fips_bin="$(find "$tmp" -type f -name fips 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$fips_bin" ]] || ! file "$fips_bin" 2>/dev/null | grep -qi ELF; then
|
||||
rm -rf "$tmp"
|
||||
print_warning "FIPS tarball did not contain an ELF 'fips' binary."
|
||||
return 1
|
||||
fi
|
||||
# Copy to a stable path so the caller can access it after we clean up
|
||||
# the extraction dir. mktemp gives a file the caller can rm later.
|
||||
stable="$(mktemp -t fips_binary.XXXXXX)"
|
||||
cp -f "$fips_bin" "$stable"
|
||||
chmod +x "$stable"
|
||||
rm -rf "$tmp"
|
||||
printf '%s' "$stable"
|
||||
}
|
||||
|
||||
# --- Rust toolchain (only for --build-fips-from-source) ------------------
|
||||
|
||||
FIPS_MIN_RUST_MAJOR=1
|
||||
FIPS_MIN_RUST_MINOR=94
|
||||
|
||||
rust_version_ok() {
|
||||
command -v cargo >/dev/null 2>&1 || return 1
|
||||
local ver
|
||||
ver="$(cargo --version 2>/dev/null | grep -oE 'cargo [0-9]+\.[0-9]+\.' | head -1 | sed 's/cargo //; s/\.$//')" || return 1
|
||||
[[ -z "$ver" ]] && return 1
|
||||
local major minor
|
||||
major="${ver%%.*}"
|
||||
minor="${ver#*.}"
|
||||
[[ "$major" -gt "$FIPS_MIN_RUST_MAJOR" ]] && return 0
|
||||
[[ "$major" -eq "$FIPS_MIN_RUST_MAJOR" && "$minor" -ge "$FIPS_MIN_RUST_MINOR" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_rust() {
|
||||
if rust_version_ok; then
|
||||
print_info "Rust toolchain OK ($(cargo --version))."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
print_warning "Installed cargo ($(cargo --version)) is older than FIPS requires (1.94.1+). Installing a newer toolchain via rustup."
|
||||
else
|
||||
print_info "No cargo found. Installing Rust toolchain via rustup."
|
||||
fi
|
||||
|
||||
local rustup_init
|
||||
rustup_init="$(mktemp -t rustup-init.XXXXXX)"
|
||||
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$rustup_init" https://sh.rustup.rs; then
|
||||
rm -f "$rustup_init"
|
||||
die "Failed to download rustup installer."
|
||||
fi
|
||||
if ! sh "$rustup_init" -y --profile minimal >/dev/null 2>&1; then
|
||||
rm -f "$rustup_init"
|
||||
die "rustup installation failed."
|
||||
fi
|
||||
rm -f "$rustup_init"
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! rust_version_ok; then
|
||||
die "Rust toolchain installed but cargo still reports an insufficient version. FIPS requires 1.94.1+."
|
||||
fi
|
||||
print_success "Rust toolchain installed: $(cargo --version)."
|
||||
}
|
||||
|
||||
# Build FIPS from source via cargo. Outputs the binary path on stdout.
|
||||
build_fips_from_source() {
|
||||
print_info "Building FIPS from source ($FIPS_REPO)..."
|
||||
ensure_rust
|
||||
|
||||
install_fips() {
|
||||
print_info "Building and installing FIPS from $FIPS_REPO (required, not optional)..."
|
||||
local tmp
|
||||
tmp="$(mktemp -d)"
|
||||
# Register cleanup for this temp dir (in addition to the global trap).
|
||||
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
|
||||
|
||||
if ! git clone --depth 1 "$FIPS_REPO" "$tmp/fips"; then
|
||||
@@ -176,40 +306,52 @@ install_fips() {
|
||||
fi
|
||||
|
||||
local fips_dir="$tmp/fips"
|
||||
local built=false
|
||||
|
||||
if [[ -x "$fips_dir/build.sh" ]]; then
|
||||
print_info "FIPS: running ./build.sh"
|
||||
if ( cd "$fips_dir" && ./build.sh ); then built=true; fi
|
||||
print_info "FIPS: running cargo build --release (this can take several minutes)..."
|
||||
if ! ( cd "$fips_dir" && cargo build --release ); then
|
||||
die "cargo build --release failed for FIPS. FIPS is required."
|
||||
fi
|
||||
|
||||
if ! $built && [[ -f "$fips_dir/Makefile" ]]; then
|
||||
print_info "FIPS: running make"
|
||||
if ( cd "$fips_dir" && make ); then built=true; fi
|
||||
fi
|
||||
|
||||
if ! $built && [[ -f "$fips_dir/CMakeLists.txt" ]]; then
|
||||
print_info "FIPS: running cmake"
|
||||
if ( cd "$fips_dir" && cmake -B build && cmake --build build ); then built=true; fi
|
||||
fi
|
||||
|
||||
if ! $built; then
|
||||
die "Could not build FIPS (no build.sh, Makefile, or CMakeLists.txt succeeded). FIPS is required."
|
||||
fi
|
||||
|
||||
# Locate the built fips binary.
|
||||
local fips_bin=""
|
||||
for cand in "$fips_dir/fips" "$fips_dir/build/fips" "$fips_dir/build/src/fips"; do
|
||||
for cand in \
|
||||
"$fips_dir/target/release/fips" \
|
||||
"$fips_dir/target/x86_64-unknown-linux-gnu/release/fips"; do
|
||||
if [[ -x "$cand" ]] && file "$cand" 2>/dev/null | grep -qi ELF; then
|
||||
fips_bin="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$fips_bin" ]]; then
|
||||
# Last resort: search for any executable named fips.
|
||||
fips_bin="$(find "$fips_dir" -type f -name fips -executable 2>/dev/null | head -1 || true)"
|
||||
fips_bin="$(find "$fips_dir/target" -type f -name fips -executable 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[[ -n "$fips_bin" ]] || die "FIPS build succeeded but no 'fips' binary was found."
|
||||
printf '%s' "$fips_bin"
|
||||
}
|
||||
|
||||
install_fips() {
|
||||
print_info "Installing FIPS (required, not optional)..."
|
||||
|
||||
# If source build was requested, install the extra build-time apt deps.
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
print_info "Installing FIPS source-build deps (libclang-dev for bindgen)..."
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
local fips_bin=""
|
||||
if $BUILD_FIPS_FROM_SOURCE; then
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
else
|
||||
if ! fips_bin="$(download_fips_prebuilt)"; then
|
||||
print_warning "Prebuilt FIPS unavailable — falling back to source build."
|
||||
# Install source-build deps on the fallback path too.
|
||||
local sudo_cmd=""
|
||||
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
|
||||
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
|
||||
fips_bin="$(build_fips_from_source)"
|
||||
fi
|
||||
fi
|
||||
[[ -n "$fips_bin" && -x "$fips_bin" ]] || die "FIPS binary not available."
|
||||
|
||||
local prefix_bin="$INSTALL_PREFIX/bin"
|
||||
local SUDO
|
||||
@@ -218,6 +360,10 @@ install_fips() {
|
||||
$SUDO cp -f "$fips_bin" "$prefix_bin/fips"
|
||||
$SUDO chmod +x "$prefix_bin/fips"
|
||||
print_success "FIPS binary installed to $prefix_bin/fips"
|
||||
# Clean up the temp binary (download_fips_prebuilt copies to a stable
|
||||
# mktemp file; build_fips_from_source uses a RETURN-trapped dir so its
|
||||
# output is already gone, but rm -f is safe on either).
|
||||
rm -f "$fips_bin" 2>/dev/null || true
|
||||
|
||||
# setcap for CAP_NET_ADMIN (needed to create a TUN interface in managed mode).
|
||||
local setcap_cmd=""
|
||||
@@ -231,7 +377,6 @@ install_fips() {
|
||||
if $setcap_cmd cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
# setcap exists but failed — try with sudo explicitly.
|
||||
if $SUDO setcap cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
|
||||
print_success "FIPS granted CAP_NET_ADMIN."
|
||||
else
|
||||
@@ -413,18 +558,29 @@ main() {
|
||||
local method="prebuilt binary from Gitea"
|
||||
$BUILD_FROM_SOURCE && method="source build from $SB_REPO"
|
||||
|
||||
cat >&2 <<EOF
|
||||
${BLUE}[INFO]${NC} sovereign_browser will be installed:
|
||||
${BLUE}[INFO]${NC} Install prefix : $INSTALL_PREFIX
|
||||
${BLUE}[INFO]${NC} Binary method : $method
|
||||
${BLUE}[INFO]${NC} FIPS : built from source ($FIPS_REPO), CAP_NET_ADMIN set
|
||||
${BLUE}[INFO]${NC} Tor : apt package 'tor'
|
||||
${BLUE}[INFO]${NC} apt deps : ${APT_PACKAGES[*]}
|
||||
EOF
|
||||
local fips_method="prebuilt binary from GitHub releases"
|
||||
$BUILD_FIPS_FROM_SOURCE && fips_method="source build ($FIPS_REPO, cargo)"
|
||||
|
||||
# Use print_info so colors render via `echo -e` (a plain `cat` heredoc
|
||||
# would print the literal \033 escape sequences).
|
||||
print_info "sovereign_browser will be installed:"
|
||||
print_info " Install prefix : $INSTALL_PREFIX"
|
||||
print_info " Binary method : $method"
|
||||
print_info " FIPS : $fips_method, CAP_NET_ADMIN set"
|
||||
print_info " Tor : apt package 'tor'"
|
||||
print_info " apt deps : ${APT_PACKAGES[*]}"
|
||||
|
||||
if ! $ASSUME_YES; then
|
||||
echo -e "${YELLOW}[WARNING]${NC} This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N] " >&2
|
||||
read -r reply || reply=""
|
||||
print_warning "This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N]"
|
||||
# Read from /dev/tty, not stdin: in `curl | bash` mode stdin is the
|
||||
# curl pipe (the script text), so a plain `read` would hit EOF and
|
||||
# abort immediately. /dev/tty is the user's terminal.
|
||||
if [[ -r /dev/tty ]]; then
|
||||
read -r reply < /dev/tty || reply=""
|
||||
else
|
||||
# No tty available (e.g. non-interactive CI) — require --yes.
|
||||
die "No tty available for confirmation. Re-run with --yes to skip prompts."
|
||||
fi
|
||||
reply="${reply:-n}"
|
||||
if [[ ! "${reply,,}" =~ ^y(es)?$ ]]; then
|
||||
die "Aborted by user."
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# FIPS Management Page — `sovereign://fips`
|
||||
|
||||
## Goal
|
||||
|
||||
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
|
||||
inside the browser. This is **not** part of the settings page — it's a
|
||||
separate status + management page, like a network control panel.
|
||||
|
||||
The page lets the user:
|
||||
|
||||
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
|
||||
tree state, ownership (managed vs attached), error messages.
|
||||
2. **See connected peers** — list of peers with their npub, address,
|
||||
transport, and connection state.
|
||||
3. **Connect to a peer** — add a new peer by npub + address + transport.
|
||||
4. **Disconnect a peer** — remove a peer connection.
|
||||
5. **Restart the FIPS service** — stop + start the managed daemon, or
|
||||
re-attach.
|
||||
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
|
||||
services, showing both on one "Network" page makes sense. Tor is
|
||||
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
|
||||
interactive management.
|
||||
|
||||
This makes `.fips` addresses actually usable: the user can add peers from
|
||||
the browser UI instead of dropping to `fipsctl` on the command line.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
|
||||
|
||||
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
|
||||
The browser already has a client:
|
||||
|
||||
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
|
||||
connection to the socket.
|
||||
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
|
||||
`{"command":"show_status"}`, parses response into `fips_status_t`
|
||||
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
|
||||
tun_active).
|
||||
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
|
||||
`{"command":"show_peers"}`, returns the `data` field as a JSON string
|
||||
(caller frees). **This is not currently called anywhere** — it exists
|
||||
but is unused.
|
||||
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
|
||||
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
|
||||
**Also unused.**
|
||||
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
|
||||
|
||||
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
|
||||
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
|
||||
needs verification against the FIPS daemon's control protocol — see
|
||||
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
|
||||
`fips_control_disconnect_peer()` function.
|
||||
|
||||
### Net services layer (`src/net_services.c` / `src/net_services.h`)
|
||||
|
||||
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
|
||||
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
|
||||
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
|
||||
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
|
||||
`control_socket[512]`.
|
||||
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
|
||||
(managed) or 5s (attached) and updates the struct. It does **not** poll
|
||||
`show_peers` — only the peer *count* is tracked.
|
||||
- The service `state` field (`service_state_t`) tracks the lifecycle:
|
||||
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
|
||||
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
|
||||
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
|
||||
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
|
||||
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
|
||||
it disables then re-enables the service.
|
||||
|
||||
### sovereign:// page pattern (`src/nostr_bridge.c`)
|
||||
|
||||
The existing pages (settings, profile, bookmarks, agents) follow this
|
||||
pattern:
|
||||
|
||||
1. **HTML page** served from an embedded `www/` file via
|
||||
[`serve_embedded_file()`](src/nostr_bridge.c:132).
|
||||
2. **CSS** served from `www/<name>.css`, linked via
|
||||
`<link href="sovereign://fips.css">`.
|
||||
3. **JS** served from `www/<name>.js`, loaded via
|
||||
`<script src="sovereign://fips.js">`.
|
||||
4. **JSON data endpoints** — `sovereign://fips/status` returns JSON,
|
||||
fetched by the JS on page load and on refresh.
|
||||
5. **Action endpoints** — `sovereign://fips/connect?npub=...&address=...&transport=...`
|
||||
performs an action and returns JSON.
|
||||
|
||||
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
|
||||
scheme handler doesn't support `fetch()` reliably for `sovereign://`
|
||||
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
|
||||
A `sovereignGet()` helper wraps XHR and returns a Promise.
|
||||
|
||||
Routing is in the main `bridge_handle_request()` function
|
||||
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
|
||||
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Status ──────────────────────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Node: npub1abc...xyz │ │
|
||||
│ │ TUN: fips0 (active) │ │
|
||||
│ │ Peers: 3 connected │ │
|
||||
│ │ Tree: healthy │ │
|
||||
│ │ Ownership: managed (spawned by browser) │ │
|
||||
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
|
||||
│ │ [Restart Service] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Peers ───────────────────────────────────────────────┐ │
|
||||
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
|
||||
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
|
||||
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
|
||||
│ │ npub: [npub1... ] │ │
|
||||
│ │ address: [host:port ] │ │
|
||||
│ │ transport: [udp ▼] │ │
|
||||
│ │ [Connect] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
|
||||
│ │ State: Ready ● │ │
|
||||
│ │ Bootstrap: 100% │ │
|
||||
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
|
||||
│ │ Ownership: attached (system Tor) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Routing
|
||||
|
||||
| URI | Handler | Returns |
|
||||
|-----|---------|---------|
|
||||
| `sovereign://fips` | serve `www/fips.html` | HTML page |
|
||||
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
|
||||
| `sovereign://fips.js` | serve `www/fips.js` | JS |
|
||||
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
|
||||
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
|
||||
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
|
||||
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
|
||||
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
|
||||
|
||||
### JSON endpoints
|
||||
|
||||
#### `sovereign://fips/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"fips": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "managed",
|
||||
"node_npub": "npub1abc...",
|
||||
"peer_count": 3,
|
||||
"tun_active": true,
|
||||
"tun_name": "fips0",
|
||||
"tree_state": "healthy",
|
||||
"daemon_state": "running",
|
||||
"control_socket": "~/.sovereign_browser/fips/control.sock",
|
||||
"error": null
|
||||
},
|
||||
"tor": {
|
||||
"state": "ready",
|
||||
"enabled": true,
|
||||
"ownership": "attached",
|
||||
"bootstrap_progress": 100,
|
||||
"socks_endpoint": "socks5://127.0.0.1:9050",
|
||||
"circuit_info": "",
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a service is `DISABLED` or `FAILED`, the fields are populated as
|
||||
best as possible and `error` holds the failure message.
|
||||
|
||||
#### `sovereign://fips/peers`
|
||||
|
||||
Returns the raw `show_peers` data from the FIPS daemon (passed through
|
||||
as-is, since the format is defined by FIPS):
|
||||
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
|
||||
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
|
||||
|
||||
#### `sovereign://fips/connect`
|
||||
|
||||
Query params: `npub`, `address`, `transport` (optional, defaults to
|
||||
`udp`).
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/disconnect`
|
||||
|
||||
Query params: `npub`.
|
||||
|
||||
Returns `{"status": "ok"}` or `{"error": "..."}`.
|
||||
|
||||
#### `sovereign://fips/restart`
|
||||
|
||||
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
|
||||
`{"status": "ok"}` (the restart is async — the JS polls
|
||||
`sovereign://fips/status` to see when it's ready again).
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
|
||||
|
||||
```c
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
```
|
||||
|
||||
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
|
||||
exact command name** against the FIPS daemon's control protocol — check
|
||||
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
|
||||
`disconnect_peer` or `remove_peer`, adjust accordingly.
|
||||
|
||||
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
|
||||
|
||||
New functions (following the existing handler pattern):
|
||||
|
||||
- `handle_fips_status_json(request)` — reads
|
||||
`net_service_get_status(NET_SERVICE_FIPS)` and
|
||||
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
|
||||
the fields above, responds as JSON.
|
||||
- `handle_fips_peers_json(request)` — opens a control connection to the
|
||||
FIPS socket (from `status.fips.control_socket`), calls
|
||||
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
|
||||
responds as JSON. If FIPS is not ready, returns empty peers + error.
|
||||
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
|
||||
`transport` from query string, opens a control connection, calls
|
||||
`fips_control_connect_peer()`, responds as JSON.
|
||||
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
|
||||
control connection, calls `fips_control_disconnect_peer()`, responds
|
||||
as JSON.
|
||||
- `handle_fips_restart(request)` — calls
|
||||
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
|
||||
|
||||
**Control connection management:** Each action handler opens a fresh
|
||||
control connection to the FIPS socket, sends the command, closes the
|
||||
connection. This is simpler than reusing the `net_service_t`'s
|
||||
`control_fd` (which is owned by the poll callback and may be in use).
|
||||
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
|
||||
|
||||
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
|
||||
|
||||
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
|
||||
line 3606), following the same pattern as the settings/bookmarks/agents
|
||||
blocks:
|
||||
|
||||
```c
|
||||
/* Route sovereign://fips — FIPS mesh management page. */
|
||||
if (strcmp(uri, "sovereign://fips") == 0 ||
|
||||
strncmp(uri, "sovereign://fips?", 18) == 0) {
|
||||
serve_embedded_file(request, "fips.html");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.css") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
|
||||
serve_embedded_file(request, "fips.css");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.js") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
|
||||
serve_embedded_file(request, "fips.js");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/status") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
|
||||
handle_fips_status_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
|
||||
handle_fips_peers_json(request);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
|
||||
handle_fips_connect(request, uri + 24);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
|
||||
handle_fips_disconnect(request, uri + 27);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
|
||||
handle_fips_restart(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create `www/fips.html`
|
||||
|
||||
The HTML page. Links `fips.css` and `fips.js`. Structure:
|
||||
|
||||
- Status section (populated by JS from `sovereign://fips/status`)
|
||||
- Peers table (populated by JS from `sovereign://fips/peers`)
|
||||
- Add peer form (npub, address, transport dropdown, Connect button)
|
||||
- Tor status section (read-only, from the same status JSON)
|
||||
- Refresh button (re-fetches status + peers)
|
||||
|
||||
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
|
||||
`sovereign-base.css` for theme variables, monospace font, dark theme.
|
||||
|
||||
### 5. Create `www/fips.css`
|
||||
|
||||
Page-specific styles. Import the base theme:
|
||||
```css
|
||||
@import "sovereign://sovereign-base.css";
|
||||
```
|
||||
|
||||
Style the status cards, peers table, add-peer form. Match the existing
|
||||
sovereign:// page aesthetic.
|
||||
|
||||
### 6. Create `www/fips.js`
|
||||
|
||||
The page logic:
|
||||
|
||||
```javascript
|
||||
/* On load: fetch status + peers, populate DOM.
|
||||
* Refresh button: re-fetch.
|
||||
* Connect form: POST to sovereign://fips/connect, then refresh peers.
|
||||
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
|
||||
* Restart button: GET sovereign://fips/restart, then poll status.
|
||||
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
|
||||
*/
|
||||
```
|
||||
|
||||
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
|
||||
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
|
||||
|
||||
### 7. Add a link to the FIPS page from the settings page
|
||||
|
||||
In `www/settings.html` (or `www/settings.js`), add a link:
|
||||
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
|
||||
section. This makes the page discoverable.
|
||||
|
||||
### 8. No Makefile changes needed
|
||||
|
||||
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
|
||||
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
|
||||
sufficient — no Makefile edit required.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
www/
|
||||
├── fips.html # FIPS management page (new)
|
||||
├── fips.css # Page styles (new)
|
||||
├── fips.js # Page logic (new)
|
||||
src/
|
||||
├── nostr_bridge.c # Add FIPS route handlers + routing
|
||||
├── fips_control.c # Add fips_control_disconnect_peer()
|
||||
├── fips_control.h # Add disconnect_peer() declaration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Status page (read-only)
|
||||
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
|
||||
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
|
||||
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
|
||||
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
|
||||
5. Show Tor status (read-only)
|
||||
6. Auto-refresh every 5s
|
||||
7. Add link from settings page
|
||||
|
||||
### Phase 2: Peer management (interactive)
|
||||
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
|
||||
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
|
||||
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
|
||||
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
|
||||
5. Add peers table + add-peer form + disconnect buttons to the page
|
||||
6. Test: connect to a known FIPS peer, verify it appears in the list,
|
||||
verify `.fips` URLs to that peer resolve in the browser
|
||||
|
||||
### Phase 3: Service control
|
||||
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
|
||||
2. Add restart button to the page
|
||||
3. Poll status during restart (state transitions: READY → STOPPING →
|
||||
DISCOVERING → STARTING → READY)
|
||||
4. Show error messages when state is FAILED
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Copy-to-clipboard for node npub
|
||||
2. QR code for node npub (reuse `sovereign://qr?text=...`)
|
||||
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
|
||||
4. Transport auto-detect (try udp, then tcp)
|
||||
5. Address book — save known peers in SQLite for re-connecting after
|
||||
restart
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build and start browser
|
||||
2. Navigate to `sovereign://fips`
|
||||
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
|
||||
4. Verify Tor status shows correctly
|
||||
5. Verify auto-refresh updates peer count if peers change
|
||||
6. If FIPS is not running:
|
||||
- Verify status shows "disabled" or "failed" with error message
|
||||
- Verify peers list is empty with "FIPS not ready" message
|
||||
7. Add a peer:
|
||||
- Enter a known FIPS peer's npub + address + transport
|
||||
- Click Connect
|
||||
- Verify peer appears in the peers list
|
||||
- Verify `http://<peer-npub>.fips/` loads in a tab
|
||||
8. Disconnect a peer:
|
||||
- Click the ✕ next to a peer
|
||||
- Verify peer disappears from the list
|
||||
9. Restart FIPS:
|
||||
- Click Restart Service
|
||||
- Verify state transitions through stopping → starting → ready
|
||||
- Verify peers reconnect (if persistent) or need re-adding
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **FIPS disconnect command name** — is it `disconnect`,
|
||||
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
|
||||
control protocol docs or `fipsctl` source. The `connect` command is
|
||||
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
|
||||
assumed.
|
||||
|
||||
2. **Peer persistence** — when the browser restarts the managed FIPS
|
||||
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
|
||||
does the user need to re-add them every time? If non-persistent,
|
||||
Phase 4's "address book" (saving peers in SQLite) becomes more
|
||||
important.
|
||||
|
||||
3. **FIPS daemon control protocol docs** — the `show_peers` response
|
||||
format is passed through as-is. We should verify the field names
|
||||
(`npub`, `address`, `transport`, `state`) match what the JS expects.
|
||||
If the format differs, the JS can adapt, but it's better to know
|
||||
upfront.
|
||||
@@ -0,0 +1,255 @@
|
||||
# FIPS Network View — `sovereign://fips/network`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the FIPS management page with a "Network" view that shows the
|
||||
**entire mesh** as the local node knows it — not just direct peers, but
|
||||
every node the daemon has discovered via bloom-filter propagation and
|
||||
Nostr discovery. This answers "what can you see on FIPS?" and "can you
|
||||
get a view of the entire network?"
|
||||
|
||||
## What the FIPS daemon exposes
|
||||
|
||||
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
|
||||
these read-only commands relevant to a network view:
|
||||
|
||||
| Command | What it returns | Network-view use |
|
||||
|---------|----------------|------------------|
|
||||
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
|
||||
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
|
||||
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
|
||||
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
|
||||
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
|
||||
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
|
||||
|
||||
### Key insight: "the entire network" = identity cache + tree
|
||||
|
||||
The FIPS daemon does **not** have a "ask peer X what peers it has"
|
||||
command. The mesh is observed through two mechanisms:
|
||||
|
||||
1. **Bloom filters** — each node broadcasts a bloom filter of node
|
||||
addresses it knows about. The local node unions these to estimate
|
||||
total mesh size (`estimated_mesh_size`) and caches individual node
|
||||
identities it has resolved (`show_identity_cache`).
|
||||
|
||||
2. **Spanning tree** — each node declares a parent; the tree
|
||||
coordinates encode paths to the root. `show_tree` gives the local
|
||||
node's tree position and its neighbors' positions.
|
||||
|
||||
So "the entire network" as seen from this node is:
|
||||
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
|
||||
last-seen). This is the node list for the network view.
|
||||
- **`show_tree`** → the local tree topology (who is my parent, who are
|
||||
my children, what is the root, what is the depth).
|
||||
- **`show_status.estimated_mesh_size`** → a single estimated total.
|
||||
|
||||
We **cannot** build a full graph of who-connects-to-whom from the local
|
||||
node's control socket alone — that would require every node to expose
|
||||
its peer list, and FIPS doesn't do that (bloom filters propagate
|
||||
*existence*, not *adjacency*). What we can show is:
|
||||
|
||||
- A **node list** (identity cache) — "these are all the nodes in the
|
||||
mesh that this node knows about."
|
||||
- A **tree view** — "this is my position in the spanning tree, my
|
||||
parent, my children, the root."
|
||||
- A **mesh summary** — "estimated N nodes total, tree depth D."
|
||||
|
||||
## Design
|
||||
|
||||
### Page layout
|
||||
|
||||
Add a "Network" tab/section to `sovereign://fips` (or a separate
|
||||
`sovereign://fips/network` page linked from the main FIPS page).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FIPS Mesh Network [Refresh] │
|
||||
│ [Status] [Peers] [Network] [Tree] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
|
||||
│ │ Estimated mesh size: 47 nodes │ │
|
||||
│ │ Known nodes (cache): 23 │ │
|
||||
│ │ Tree depth: 4 │ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our position: depth 2, parent npub1def... │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
|
||||
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
|
||||
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
|
||||
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
|
||||
│ │ ... │ │
|
||||
│ │ (● = direct peer, ○ = known via discovery only) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
|
||||
│ │ Root: npub1abc... (laantungir) │ │
|
||||
│ │ Our parent: npub1def... (fips.v0l.io) │ │
|
||||
│ │ Our children: │ │
|
||||
│ │ npub1jkl... depth 3 distance 1 │ │
|
||||
│ │ npub1mno... depth 3 distance 1 │ │
|
||||
│ │ Tree peers (1-hop): │ │
|
||||
│ │ npub1abc... depth 0 root distance 2 │ │
|
||||
│ │ npub1def... depth 1 parent distance 1 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### New JSON endpoints
|
||||
|
||||
| URI | FIPS command | Returns |
|
||||
|-----|-------------|---------|
|
||||
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
|
||||
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
|
||||
|
||||
#### `sovereign://fips/network`
|
||||
|
||||
Calls three FIPS commands on a fresh control connection and combines
|
||||
the results:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"estimated_mesh_size": 47,
|
||||
"known_nodes": 23,
|
||||
"max_cache_entries": 256,
|
||||
"tree_depth": 4,
|
||||
"is_root": false,
|
||||
"root_npub": "npub1abc...",
|
||||
"root_display_name": "laantungir",
|
||||
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
|
||||
"our_parent": "npub1def...",
|
||||
"our_parent_display_name": "fips.v0l.io"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_addr": "e3da8293...",
|
||||
"npub": "npub1abc...",
|
||||
"display_name": "laantungir",
|
||||
"ipv6_addr": "fde3:da82:...",
|
||||
"last_seen_ms": 1784309108403,
|
||||
"age_ms": 2000,
|
||||
"is_direct_peer": true
|
||||
}
|
||||
],
|
||||
"tree": {
|
||||
"root": "1b4788b7...",
|
||||
"root_npub": "npub1abc...",
|
||||
"is_root": false,
|
||||
"depth": 2,
|
||||
"parent": "9d1192e8...",
|
||||
"parent_display_name": "fips.v0l.io",
|
||||
"peers": [
|
||||
{
|
||||
"node_addr": "1b4788b7...",
|
||||
"display_name": "laantungir",
|
||||
"depth": 0,
|
||||
"distance_to_us": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `is_direct_peer` flag on each node is computed by cross-referencing
|
||||
the identity cache entries against the `show_peers` npub list (so the
|
||||
UI can mark direct peers vs. discovery-only nodes).
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
|
||||
|
||||
These follow the existing `fips_control_show_peers()` pattern — send
|
||||
the command, return the `data` field as a JSON string (caller frees):
|
||||
|
||||
```c
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
```
|
||||
|
||||
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
|
||||
|
||||
Opens a control connection, calls `show_status`, `show_peers`,
|
||||
`show_identity_cache`, and `show_tree`, then combines them into the
|
||||
JSON above. The `is_direct_peer` flag is computed by building a set of
|
||||
direct-peer npubs from `show_peers` and checking each identity-cache
|
||||
entry against it.
|
||||
|
||||
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
|
||||
|
||||
```c
|
||||
if (strcmp(uri, "sovereign://fips/network") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
|
||||
handle_fips_network_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
|
||||
handle_fips_tree_json(request);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
|
||||
|
||||
Add tab navigation (Status | Peers | Network | Tree) to the page. The
|
||||
Network tab fetches `sovereign://fips/network` and renders:
|
||||
- Mesh summary card (estimated size, known nodes, depth, root, our
|
||||
position).
|
||||
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
|
||||
- Spanning tree card (root, parent, children, 1-hop peers with
|
||||
distances).
|
||||
|
||||
The Tree tab fetches `sovereign://fips/tree` and renders a more
|
||||
detailed tree view (coords paths, declaration sequence).
|
||||
|
||||
#### 5. Auto-refresh
|
||||
|
||||
The Network tab polls `sovereign://fips/network` every 5s (same as the
|
||||
status poll).
|
||||
|
||||
## Limitations (what we cannot show)
|
||||
|
||||
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
|
||||
Bloom filters propagate node *existence*, not *edges*. To build a
|
||||
full graph, every node would need to expose its peer list (a future
|
||||
FIPS protocol extension), or the browser would need to query each
|
||||
node's control socket individually (not practical across the mesh).
|
||||
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
|
||||
and `age_ms`, so we can show staleness, but there's no event stream
|
||||
for join/leave (would need a FIPS control subscription protocol).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/
|
||||
├── fips_control.c # Add show_tree(), show_identity_cache()
|
||||
├── fips_control.h # Add declarations
|
||||
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
|
||||
www/
|
||||
├── fips.html # Add Network + Tree tabs
|
||||
├── fips.css # Tab + network-table styles
|
||||
├── fips.js # Network + tree rendering, tab switching
|
||||
```
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1: Network summary + known nodes
|
||||
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
|
||||
2. Add `handle_fips_network_json()` combining status + peers + identity cache
|
||||
3. Add `sovereign://fips/network` route
|
||||
4. Add "Network" tab to the page with mesh summary + known-nodes table
|
||||
5. Mark direct peers with a badge
|
||||
|
||||
### Phase 2: Spanning tree view
|
||||
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
|
||||
2. Add `sovereign://fips/tree` route
|
||||
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
|
||||
4. Optional: simple ASCII/indented tree rendering
|
||||
|
||||
### Phase 3: Polish
|
||||
1. Sort known nodes by last-seen (most recent first)
|
||||
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
|
||||
3. Click a node to see its detail (ipv6, age, whether direct peer)
|
||||
4. Copy-to-clipboard for npub / ipv6
|
||||
5. Optional: bloom-filter visualization (advanced/debug tab)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -182,6 +182,32 @@ char *fips_control_show_peers(int fd, char *error, size_t error_size) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Generic single-command "show_*" that returns the data field as
|
||||
* compact JSON. Used by show_tree and show_identity_cache, which have
|
||||
* the same response shape as show_peers. */
|
||||
static char *fips_control_show_generic(int fd, const char *command,
|
||||
char *error, size_t error_size) {
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", command);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return NULL;
|
||||
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
|
||||
char *result = printed ? g_strdup(printed) : NULL;
|
||||
cJSON_free(printed);
|
||||
cJSON_Delete(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_tree", error, error_size);
|
||||
}
|
||||
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size) {
|
||||
return fips_control_show_generic(fd, "show_identity_cache", error, error_size);
|
||||
}
|
||||
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size) {
|
||||
@@ -202,6 +228,23 @@ int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
return 0;
|
||||
}
|
||||
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size) {
|
||||
if (!npub || !npub[0]) {
|
||||
set_error(error, error_size, "%s", "FIPS peer npub is required");
|
||||
return -1;
|
||||
}
|
||||
cJSON *cmd = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(cmd, "command", "disconnect");
|
||||
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
|
||||
cJSON_AddStringToObject(params, "npub", npub);
|
||||
cJSON *root = request(fd, cmd, error, error_size);
|
||||
cJSON_Delete(cmd);
|
||||
if (!root) return -1;
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void fips_control_close(int *fd) {
|
||||
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
|
||||
}
|
||||
|
||||
+7
-2
@@ -26,9 +26,14 @@ int fips_control_show_status(int fd, fips_status_t *status,
|
||||
char *error, size_t error_size);
|
||||
/* Returns the response data as compact JSON. Caller frees with g_free(). */
|
||||
char *fips_control_show_peers(int fd, char *error, size_t error_size);
|
||||
/* show_tree / show_identity_cache — same contract as show_peers. */
|
||||
char *fips_control_show_tree(int fd, char *error, size_t error_size);
|
||||
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
|
||||
int fips_control_connect_peer(int fd, const char *npub, const char *address,
|
||||
const char *transport,
|
||||
char *error, size_t error_size);
|
||||
const char *transport,
|
||||
char *error, size_t error_size);
|
||||
int fips_control_disconnect_peer(int fd, const char *npub,
|
||||
char *error, size_t error_size);
|
||||
void fips_control_close(int *fd);
|
||||
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms);
|
||||
|
||||
|
||||
+94
-1
@@ -313,6 +313,21 @@ void on_menu_agent(GtkMenuItem *item, gpointer data) {
|
||||
}
|
||||
}
|
||||
|
||||
/* The hamburger-menu "FIPS Mesh…" item navigates the active tab to
|
||||
* the sovereign://fips internal page, which renders the FIPS mesh
|
||||
* network status + management UI. */
|
||||
void on_menu_fips(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab && tab->webview) {
|
||||
webkit_web_view_load_uri(tab->webview, "sovereign://fips");
|
||||
} else {
|
||||
tab_manager_new_tab("sovereign://fips");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Keyboard shortcuts --------------------------------------------- *
|
||||
* All browser-level shortcuts are configurable via the shortcuts module.
|
||||
* The on_key_press handler looks up the action for the pressed key
|
||||
@@ -490,7 +505,83 @@ static void agent_login_callback(void) {
|
||||
g_print("[login] Agent login detected.\n");
|
||||
}
|
||||
|
||||
/* ---- Window destroy ------------------------------------------------- */
|
||||
/* ---- Window close / destroy ----------------------------------------- *
|
||||
* The main window uses a delete-event handler to intercept the window
|
||||
* manager's close request BEFORE the window is destroyed. This lets us
|
||||
* close the main window's tabs cleanly (updating g_tab_count) and keep
|
||||
* the app running if auxiliary windows still have tabs. The app only
|
||||
* quits when the last window is closed (no tabs remain anywhere).
|
||||
*
|
||||
* delete-event: runs first. Closes all main-window tabs. If aux windows
|
||||
* still have tabs, returns TRUE to suppress destroy and hides the
|
||||
* main window. If no tabs remain, returns FALSE to let destroy
|
||||
* proceed → on_window_destroy → gtk_main_quit().
|
||||
* destroy: runs only when the app is truly shutting down. Performs
|
||||
* session save, net_services_shutdown, agent_server_stop, etc.
|
||||
*/
|
||||
|
||||
/* Guard flag: set while on_window_delete_event is closing the main
|
||||
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
|
||||
* calls gtk_window_close(g_window) after the last tab is closed, which
|
||||
* would re-emit delete-event. */
|
||||
static gboolean g_main_window_closing = FALSE;
|
||||
|
||||
static gboolean on_window_delete_event(GtkWidget *widget,
|
||||
GdkEvent *event,
|
||||
gpointer data) {
|
||||
(void)event;
|
||||
(void)data;
|
||||
|
||||
/* If we're already in the process of closing (re-entrant call from
|
||||
* tab_manager_close_tab → gtk_window_close), let the destroy
|
||||
* proceed. */
|
||||
if (g_main_window_closing) {
|
||||
return FALSE;
|
||||
}
|
||||
g_main_window_closing = TRUE;
|
||||
|
||||
/* Close all tabs that belong to the main window's notebook. We
|
||||
* identify main-window tabs by checking gtk_notebook_page_num on
|
||||
* the main notebook. tab_manager_close_tab handles removing from
|
||||
* the notebook and the g_tabs array. Re-scan each iteration because
|
||||
* closing shifts indices. */
|
||||
for (;;) {
|
||||
gboolean found = FALSE;
|
||||
GtkWidget *main_nb = tab_manager_get_main_notebook();
|
||||
if (main_nb == NULL) break;
|
||||
/* Close from the highest index down so removal doesn't shift
|
||||
* unprocessed indices. Find the highest-index tab in the main
|
||||
* notebook and close it. */
|
||||
for (int i = tab_manager_count() - 1; i >= 0; i--) {
|
||||
tab_info_t *tab = tab_manager_get(i);
|
||||
if (tab == NULL || tab->page == NULL) continue;
|
||||
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
|
||||
tab->page) >= 0) {
|
||||
tab_manager_close_tab(i);
|
||||
found = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
|
||||
/* If auxiliary windows still have tabs, keep the app running.
|
||||
* Suppress the default destroy by returning TRUE, and hide the
|
||||
* main window. The aux windows continue independently. */
|
||||
if (tab_manager_count() > 0) {
|
||||
g_print("[windows] Main window closed but %d tab(s) remain in "
|
||||
"other window(s) — keeping app alive.\n",
|
||||
tab_manager_count());
|
||||
gtk_widget_hide(widget);
|
||||
g_main_window_closing = FALSE; /* allow re-opening later */
|
||||
return TRUE; /* suppress destroy */
|
||||
}
|
||||
|
||||
/* No tabs left anywhere — let the destroy proceed, which triggers
|
||||
* on_window_destroy and quits the app. Keep the guard set so any
|
||||
* re-entrant delete-event from the destroy path doesn't re-enter. */
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_window_destroy(GtkWidget *widget, gpointer data) {
|
||||
(void)widget;
|
||||
@@ -746,6 +837,8 @@ int main(int argc, char **argv) {
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
|
||||
g_signal_connect(window, "delete-event",
|
||||
G_CALLBACK(on_window_delete_event), NULL);
|
||||
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
|
||||
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
|
||||
g_window = GTK_WINDOW(window);
|
||||
|
||||
+723
-1
@@ -31,6 +31,9 @@
|
||||
#include "agent_skills.h"
|
||||
#include "agent_llm.h"
|
||||
#include "embedded_web_content.h"
|
||||
#include "net_services.h"
|
||||
#include "fips_control.h"
|
||||
#include "relay_fetch.h"
|
||||
|
||||
/* ── Global bridge state ────────────────────────────────────────── *
|
||||
* The URI scheme callback needs access to the current signer. We keep
|
||||
@@ -3043,8 +3046,672 @@ static void handle_settings_config_json(WebKitURISchemeRequest *request) {
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* ── FIPS mesh management page (sovereign://fips) ─────────────────── */
|
||||
|
||||
/* Map a service_state_t to a lowercase string for the JSON status. */
|
||||
static const char *service_state_name(service_state_t s) {
|
||||
switch (s) {
|
||||
case SERVICE_DISABLED: return "disabled";
|
||||
case SERVICE_DISCOVERING: return "discovering";
|
||||
case SERVICE_ATTACHING: return "attaching";
|
||||
case SERVICE_STARTING: return "starting";
|
||||
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
|
||||
case SERVICE_READY: return "ready";
|
||||
case SERVICE_STOPPING: return "stopping";
|
||||
case SERVICE_EXITED: return "exited";
|
||||
case SERVICE_FAILED: return "failed";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* Map a service_ownership_t to a lowercase string. */
|
||||
static const char *service_ownership_name(service_ownership_t o) {
|
||||
switch (o) {
|
||||
case OWNERSHIP_NONE: return "none";
|
||||
case OWNERSHIP_ATTACHED: return "attached";
|
||||
case OWNERSHIP_MANAGED: return "managed";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/status — return FIPS + Tor status as JSON.
|
||||
* Used by www/fips.js to populate the status cards. */
|
||||
static void handle_fips_status_json(WebKitURISchemeRequest *request) {
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
/* FIPS status block. */
|
||||
cJSON *f = cJSON_CreateObject();
|
||||
if (fips) {
|
||||
cJSON_AddStringToObject(f, "state",
|
||||
service_state_name(fips->state));
|
||||
cJSON_AddBoolToObject(f, "enabled",
|
||||
net_service_is_enabled(NET_SERVICE_FIPS));
|
||||
cJSON_AddStringToObject(f, "ownership",
|
||||
service_ownership_name(fips->ownership));
|
||||
cJSON_AddStringToObject(f, "node_npub",
|
||||
fips->status.fips.node_npub);
|
||||
cJSON_AddNumberToObject(f, "peer_count",
|
||||
fips->status.fips.peer_count);
|
||||
cJSON_AddBoolToObject(f, "tun_active",
|
||||
fips->status.fips.tun_active);
|
||||
cJSON_AddStringToObject(f, "tun_name",
|
||||
fips->status.fips.tun_name);
|
||||
cJSON_AddStringToObject(f, "tree_state",
|
||||
fips->status.fips.tree_state);
|
||||
cJSON_AddStringToObject(f, "daemon_state",
|
||||
fips->status.fips.daemon_state);
|
||||
cJSON_AddStringToObject(f, "control_socket",
|
||||
fips->status.fips.control_socket);
|
||||
cJSON_AddStringToObject(f, "error",
|
||||
fips->error_msg ? fips->error_msg : "");
|
||||
} else {
|
||||
cJSON_AddStringToObject(f, "state", "disabled");
|
||||
cJSON_AddStringToObject(f, "error", "FIPS service unavailable");
|
||||
}
|
||||
cJSON_AddItemToObject(root, "fips", f);
|
||||
|
||||
/* Tor status block (read-only). */
|
||||
cJSON *t = cJSON_CreateObject();
|
||||
if (tor) {
|
||||
cJSON_AddStringToObject(t, "state",
|
||||
service_state_name(tor->state));
|
||||
cJSON_AddBoolToObject(t, "enabled",
|
||||
net_service_is_enabled(NET_SERVICE_TOR));
|
||||
cJSON_AddStringToObject(t, "ownership",
|
||||
service_ownership_name(tor->ownership));
|
||||
cJSON_AddNumberToObject(t, "bootstrap_progress",
|
||||
tor->status.tor.bootstrap_progress);
|
||||
cJSON_AddStringToObject(t, "socks_endpoint",
|
||||
tor->status.tor.socks_endpoint);
|
||||
cJSON_AddStringToObject(t, "circuit_info",
|
||||
tor->status.tor.circuit_info);
|
||||
cJSON_AddStringToObject(t, "error",
|
||||
tor->error_msg ? tor->error_msg : "");
|
||||
} else {
|
||||
cJSON_AddStringToObject(t, "state", "disabled");
|
||||
cJSON_AddStringToObject(t, "error", "Tor service unavailable");
|
||||
}
|
||||
cJSON_AddItemToObject(root, "tor", t);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/peers — return the FIPS peer list as JSON.
|
||||
* Calls fips_control_show_peers() on a fresh control connection. */
|
||||
static void handle_fips_peers_json(WebKitURISchemeRequest *request) {
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON *peers = cJSON_CreateArray();
|
||||
|
||||
if (!fips || fips->state != SERVICE_READY ||
|
||||
!fips->status.fips.control_socket[0]) {
|
||||
cJSON_AddItemToObject(root, "peers", peers);
|
||||
cJSON_AddStringToObject(root, "error", "FIPS not ready");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
char error[256] = "";
|
||||
int fd = fips_control_connect(fips->status.fips.control_socket,
|
||||
500, error, sizeof(error));
|
||||
if (fd < 0) {
|
||||
cJSON_AddItemToObject(root, "peers", peers);
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
char *data = fips_control_show_peers(fd, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
|
||||
if (data) {
|
||||
/* The FIPS daemon returns the data field as a JSON value. It may
|
||||
* be an array of peers directly, or an object wrapping a "peers"
|
||||
* array (e.g. {"peers":[...]}). Handle both shapes. */
|
||||
cJSON *parsed = cJSON_Parse(data);
|
||||
if (cJSON_IsArray(parsed)) {
|
||||
cJSON_AddItemToObject(root, "peers", parsed);
|
||||
} else if (cJSON_IsObject(parsed)) {
|
||||
cJSON *inner = cJSON_GetObjectItemCaseSensitive(parsed, "peers");
|
||||
if (cJSON_IsArray(inner)) {
|
||||
cJSON_AddItemToObject(root, "peers",
|
||||
cJSON_Duplicate(inner, TRUE));
|
||||
}
|
||||
cJSON_Delete(parsed);
|
||||
} else {
|
||||
if (parsed) cJSON_Delete(parsed);
|
||||
cJSON_AddItemToObject(root, "peers", peers);
|
||||
}
|
||||
g_free(data);
|
||||
} else {
|
||||
cJSON_AddItemToObject(root, "peers", peers);
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/connect?npub=...&address=...&transport=...
|
||||
* Opens a control connection and calls fips_control_connect_peer(). */
|
||||
static void handle_fips_connect(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
char *npub = query_param(query, "npub");
|
||||
char *address = query_param(query, "address");
|
||||
char *transport = query_param(query, "transport");
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
if (!npub || !npub[0] || !address || !address[0]) {
|
||||
cJSON_AddStringToObject(root, "error",
|
||||
"npub and address are required");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub); g_free(address); g_free(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
if (!fips || !fips->status.fips.control_socket[0]) {
|
||||
cJSON_AddStringToObject(root, "error", "FIPS control socket unavailable");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub); g_free(address); g_free(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
char error[256] = "";
|
||||
int fd = fips_control_connect(fips->status.fips.control_socket,
|
||||
500, error, sizeof(error));
|
||||
if (fd < 0) {
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub); g_free(address); g_free(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = fips_control_connect_peer(fd, npub, address,
|
||||
transport && transport[0] ? transport : "udp",
|
||||
error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
|
||||
if (rc == 0) cJSON_AddStringToObject(root, "status", "ok");
|
||||
else cJSON_AddStringToObject(root, "error", error);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub); g_free(address); g_free(transport);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/disconnect?npub=...
|
||||
* Opens a control connection and calls fips_control_disconnect_peer(). */
|
||||
static void handle_fips_disconnect(WebKitURISchemeRequest *request,
|
||||
const char *query) {
|
||||
char *npub = query_param(query, "npub");
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
if (!npub || !npub[0]) {
|
||||
cJSON_AddStringToObject(root, "error", "npub is required");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub);
|
||||
return;
|
||||
}
|
||||
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
if (!fips || !fips->status.fips.control_socket[0]) {
|
||||
cJSON_AddStringToObject(root, "error", "FIPS control socket unavailable");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub);
|
||||
return;
|
||||
}
|
||||
|
||||
char error[256] = "";
|
||||
int fd = fips_control_connect(fips->status.fips.control_socket,
|
||||
500, error, sizeof(error));
|
||||
if (fd < 0) {
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub);
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = fips_control_disconnect_peer(fd, npub, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
|
||||
if (rc == 0) cJSON_AddStringToObject(root, "status", "ok");
|
||||
else cJSON_AddStringToObject(root, "error", error);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
g_free(npub);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/restart — restart the managed FIPS service.
|
||||
* The restart is async; the JS polls sovereign://fips/status. */
|
||||
static void handle_fips_restart(WebKitURISchemeRequest *request) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
int rc = net_service_restart(NET_SERVICE_FIPS);
|
||||
if (rc == 0) cJSON_AddStringToObject(root, "status", "ok");
|
||||
else cJSON_AddStringToObject(root, "error", "FIPS restart failed");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/tree — pass-through of the FIPS daemon's
|
||||
* show_tree response (spanning tree state). Used by the Tree tab. */
|
||||
static void handle_fips_tree_json(WebKitURISchemeRequest *request) {
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
if (!fips || fips->state != SERVICE_READY ||
|
||||
!fips->status.fips.control_socket[0]) {
|
||||
cJSON_AddStringToObject(root, "error", "FIPS not ready");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
char error[256] = "";
|
||||
int fd = fips_control_connect(fips->status.fips.control_socket,
|
||||
500, error, sizeof(error));
|
||||
if (fd < 0) {
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
char *data = fips_control_show_tree(fd, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
|
||||
if (data) {
|
||||
cJSON *parsed = cJSON_Parse(data);
|
||||
if (parsed) {
|
||||
cJSON_AddItemToObject(root, "tree", parsed);
|
||||
} else {
|
||||
cJSON_AddStringToObject(root, "error", "invalid tree JSON");
|
||||
}
|
||||
g_free(data);
|
||||
} else {
|
||||
cJSON_AddStringToObject(root, "error", error);
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/network — combined network view: mesh summary
|
||||
* (from show_status), known nodes (from show_identity_cache), direct
|
||||
* peers (from show_peers, to flag is_direct_peer), and spanning tree
|
||||
* (from show_tree). Used by the Network tab. */
|
||||
static void handle_fips_network_json(WebKitURISchemeRequest *request) {
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
if (!fips || fips->state != SERVICE_READY ||
|
||||
!fips->status.fips.control_socket[0]) {
|
||||
cJSON_AddStringToObject(root, "error", "FIPS not ready");
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
char error[256] = "";
|
||||
const char *sock = fips->status.fips.control_socket;
|
||||
|
||||
/* Helper: open a fresh control connection for a single command.
|
||||
* The FIPS daemon closes the socket after each response, so we
|
||||
* cannot reuse one fd across multiple commands. */
|
||||
#define FIPS_RECONNECT() \
|
||||
fips_control_connect(sock, 500, error, sizeof(error))
|
||||
|
||||
/* 1. Mesh summary from show_status. */
|
||||
cJSON *summary = cJSON_CreateObject();
|
||||
int fd = FIPS_RECONNECT();
|
||||
if (fd >= 0) {
|
||||
fips_status_t status;
|
||||
if (fips_control_show_status(fd, &status, error, sizeof(error)) == 0) {
|
||||
cJSON_AddNumberToObject(root, "peer_count", status.peer_count);
|
||||
cJSON_AddStringToObject(summary, "node_npub", status.npub);
|
||||
cJSON_AddStringToObject(summary, "daemon_state", status.state);
|
||||
cJSON_AddStringToObject(summary, "tree_state", status.tree_state);
|
||||
cJSON_AddStringToObject(summary, "tun_name", status.tun_name);
|
||||
cJSON_AddBoolToObject(summary, "tun_active", status.tun_active);
|
||||
} else {
|
||||
cJSON_AddStringToObject(summary, "error", error);
|
||||
}
|
||||
fips_control_close(&fd);
|
||||
} else {
|
||||
cJSON_AddStringToObject(summary, "error", error);
|
||||
}
|
||||
|
||||
/* 2. Direct peers (to build the is_direct_peer set + count). */
|
||||
cJSON *direct_npubs = cJSON_CreateArray();
|
||||
fd = FIPS_RECONNECT();
|
||||
if (fd >= 0) {
|
||||
char *peers_data = fips_control_show_peers(fd, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
if (peers_data) {
|
||||
cJSON *peers_parsed = cJSON_Parse(peers_data);
|
||||
if (peers_parsed) {
|
||||
/* show_peers data is {"peers":[...]} or a bare array. */
|
||||
cJSON *peers_arr = NULL;
|
||||
if (cJSON_IsArray(peers_parsed)) {
|
||||
peers_arr = peers_parsed;
|
||||
} else if (cJSON_IsObject(peers_parsed)) {
|
||||
peers_arr = cJSON_GetObjectItemCaseSensitive(peers_parsed, "peers");
|
||||
}
|
||||
if (cJSON_IsArray(peers_arr)) {
|
||||
cJSON *p;
|
||||
cJSON_ArrayForEach(p, peers_arr) {
|
||||
cJSON *npub = cJSON_GetObjectItemCaseSensitive(p, "npub");
|
||||
if (cJSON_IsString(npub)) {
|
||||
cJSON_AddItemToArray(direct_npubs,
|
||||
cJSON_CreateString(npub->valuestring));
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(peers_parsed);
|
||||
}
|
||||
g_free(peers_data);
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. Known nodes from show_identity_cache. */
|
||||
cJSON *nodes = cJSON_CreateArray();
|
||||
fd = FIPS_RECONNECT();
|
||||
if (fd >= 0) {
|
||||
char *id_data = fips_control_show_identity_cache(fd, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
if (id_data) {
|
||||
cJSON *id_parsed = cJSON_Parse(id_data);
|
||||
if (id_parsed) {
|
||||
cJSON *entries = cJSON_GetObjectItemCaseSensitive(id_parsed, "entries");
|
||||
if (cJSON_IsArray(entries)) {
|
||||
cJSON *e;
|
||||
cJSON_ArrayForEach(e, entries) {
|
||||
cJSON *npub = cJSON_GetObjectItemCaseSensitive(e, "npub");
|
||||
if (!cJSON_IsString(npub)) continue;
|
||||
/* Check if this node is a direct peer. */
|
||||
gboolean is_direct = FALSE;
|
||||
cJSON *d;
|
||||
cJSON_ArrayForEach(d, direct_npubs) {
|
||||
if (cJSON_IsString(d) &&
|
||||
strcmp(d->valuestring, npub->valuestring) == 0) {
|
||||
is_direct = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON *node_obj = cJSON_Duplicate(e, TRUE);
|
||||
cJSON_AddBoolToObject(node_obj, "is_direct_peer", is_direct);
|
||||
cJSON_AddItemToArray(nodes, node_obj);
|
||||
}
|
||||
}
|
||||
cJSON *count = cJSON_GetObjectItemCaseSensitive(id_parsed, "count");
|
||||
if (cJSON_IsNumber(count)) {
|
||||
cJSON_AddNumberToObject(summary, "known_nodes", count->valueint);
|
||||
}
|
||||
cJSON *max = cJSON_GetObjectItemCaseSensitive(id_parsed, "max_entries");
|
||||
if (cJSON_IsNumber(max)) {
|
||||
cJSON_AddNumberToObject(summary, "max_cache_entries", max->valueint);
|
||||
}
|
||||
cJSON_Delete(id_parsed);
|
||||
}
|
||||
g_free(id_data);
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. Spanning tree from show_tree. */
|
||||
fd = FIPS_RECONNECT();
|
||||
if (fd >= 0) {
|
||||
char *tree_data = fips_control_show_tree(fd, error, sizeof(error));
|
||||
fips_control_close(&fd);
|
||||
if (tree_data) {
|
||||
cJSON *tree_parsed = cJSON_Parse(tree_data);
|
||||
if (tree_parsed) {
|
||||
/* Extract summary fields for the header. */
|
||||
cJSON *root_npub = cJSON_GetObjectItemCaseSensitive(tree_parsed, "root_npub");
|
||||
cJSON *is_root = cJSON_GetObjectItemCaseSensitive(tree_parsed, "is_root");
|
||||
cJSON *depth = cJSON_GetObjectItemCaseSensitive(tree_parsed, "depth");
|
||||
cJSON *parent_disp = cJSON_GetObjectItemCaseSensitive(tree_parsed, "parent_display_name");
|
||||
if (cJSON_IsString(root_npub)) {
|
||||
cJSON_AddStringToObject(summary, "root_npub", root_npub->valuestring);
|
||||
}
|
||||
if (cJSON_IsBool(is_root)) {
|
||||
cJSON_AddBoolToObject(summary, "is_root",
|
||||
cJSON_IsTrue(is_root) ? TRUE : FALSE);
|
||||
}
|
||||
if (cJSON_IsNumber(depth)) {
|
||||
cJSON_AddNumberToObject(summary, "tree_depth", depth->valueint);
|
||||
}
|
||||
if (cJSON_IsString(parent_disp)) {
|
||||
cJSON_AddStringToObject(summary, "parent_display_name",
|
||||
parent_disp->valuestring);
|
||||
}
|
||||
cJSON_AddItemToObject(root, "tree", tree_parsed);
|
||||
}
|
||||
g_free(tree_data);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "summary", summary);
|
||||
cJSON_AddItemToObject(root, "nodes", nodes);
|
||||
cJSON_Delete(direct_npubs);
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://fips/directory — query the FIPS advert relays for
|
||||
* all Kind 37195 / "fips-overlay-v1" events (the global node directory,
|
||||
* same data source as join.fips.network). Returns each advertised node
|
||||
* with its npub, endpoints, and signal relays. Cross-references the
|
||||
* local FIPS daemon's direct peers to flag which are already connected.
|
||||
*
|
||||
* This is a synchronous relay query (may take a few seconds). The JS
|
||||
* shows a loading indicator while waiting. */
|
||||
static void handle_fips_directory_json(WebKitURISchemeRequest *request) {
|
||||
/* Default FIPS advert relays (from fips/src/config/node.rs). */
|
||||
static const char *advert_relays[] = {
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://offchain.pub",
|
||||
};
|
||||
const int relay_count = (int)(sizeof(advert_relays) / sizeof(advert_relays[0]));
|
||||
|
||||
/* Build the Nostr filter: {"kinds":[37195], "#d":["fips-overlay-v1"],
|
||||
* "limit":500}. Kind 37195 is parameterized-replaceable, so each
|
||||
* author's newest advert supersedes older ones; ALL_RESULTS
|
||||
* deduplicates by event id, and we keep the newest per pubkey. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(37195));
|
||||
cJSON *d_tags = cJSON_AddArrayToObject(filter, "#d");
|
||||
cJSON_AddItemToArray(d_tags, cJSON_CreateString("fips-overlay-v1"));
|
||||
cJSON_AddNumberToObject(filter, "limit", 500);
|
||||
|
||||
int result_count = 0;
|
||||
cJSON **results = relay_query_synchronized(
|
||||
advert_relays, relay_count, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&result_count, 10, NULL, NULL, 0, NULL);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON *nodes = cJSON_CreateArray();
|
||||
|
||||
/* Collect direct-peer npubs from the local FIPS daemon (if ready)
|
||||
* so we can flag is_direct_peer in the directory. */
|
||||
cJSON *direct_npubs = cJSON_CreateArray();
|
||||
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
|
||||
if (fips && fips->state == SERVICE_READY &&
|
||||
fips->status.fips.control_socket[0]) {
|
||||
char err[256] = "";
|
||||
int fd = fips_control_connect(fips->status.fips.control_socket,
|
||||
500, err, sizeof(err));
|
||||
if (fd >= 0) {
|
||||
char *peers_data = fips_control_show_peers(fd, err, sizeof(err));
|
||||
fips_control_close(&fd);
|
||||
if (peers_data) {
|
||||
cJSON *pp = cJSON_Parse(peers_data);
|
||||
if (pp) {
|
||||
cJSON *arr = cJSON_IsArray(pp) ? pp :
|
||||
cJSON_GetObjectItemCaseSensitive(pp, "peers");
|
||||
if (cJSON_IsArray(arr)) {
|
||||
cJSON *p;
|
||||
cJSON_ArrayForEach(p, arr) {
|
||||
cJSON *n = cJSON_GetObjectItemCaseSensitive(p, "npub");
|
||||
if (cJSON_IsString(n))
|
||||
cJSON_AddItemToArray(direct_npubs,
|
||||
cJSON_CreateString(n->valuestring));
|
||||
}
|
||||
}
|
||||
cJSON_Delete(pp);
|
||||
}
|
||||
g_free(peers_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Process relay results: keep the newest event per pubkey (Kind 37195
|
||||
* is parameterized-replaceable, but different relays may return
|
||||
* different versions; we want the latest created_at per author). */
|
||||
if (results && result_count > 0) {
|
||||
/* Build a map of pubkey -> (created_at, node_obj) for dedup. */
|
||||
cJSON *map = cJSON_CreateObject();
|
||||
for (int i = 0; i < result_count; i++) {
|
||||
if (!results[i]) continue;
|
||||
cJSON *ev = results[i];
|
||||
cJSON *pk = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
|
||||
cJSON *ca = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
if (!cJSON_IsString(pk)) continue;
|
||||
const char *pubkey_hex = pk->valuestring;
|
||||
gint64 created = cJSON_IsNumber(ca) ? (gint64)ca->valuedouble : 0;
|
||||
|
||||
/* Convert hex pubkey to npub. */
|
||||
unsigned char pubkey_bytes[32];
|
||||
char npub[128] = "";
|
||||
if (nostr_hex_to_bytes(pubkey_hex, pubkey_bytes, 32) == 0) {
|
||||
nostr_key_to_bech32(pubkey_bytes, "npub", npub);
|
||||
}
|
||||
|
||||
/* Parse the advert content JSON. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(ev, "content");
|
||||
cJSON *advert = cJSON_IsString(content)
|
||||
? cJSON_Parse(content->valuestring) : NULL;
|
||||
|
||||
/* Check if this is newer than the existing entry for this npub. */
|
||||
cJSON *existing = cJSON_GetObjectItemCaseSensitive(map, npub);
|
||||
if (existing) {
|
||||
cJSON *ex_ca = cJSON_GetObjectItemCaseSensitive(existing, "created_at");
|
||||
gint64 ex_created = cJSON_IsNumber(ex_ca) ? (gint64)ex_ca->valuedouble : 0;
|
||||
if (created <= ex_created) {
|
||||
if (advert) cJSON_Delete(advert);
|
||||
continue;
|
||||
}
|
||||
/* Replace: remove old from nodes array. */
|
||||
cJSON_DeleteItemFromObject(map, npub);
|
||||
}
|
||||
|
||||
/* Build the node object. */
|
||||
cJSON *node_obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(node_obj, "npub", npub);
|
||||
cJSON_AddStringToObject(node_obj, "pubkey_hex", pubkey_hex);
|
||||
cJSON_AddNumberToObject(node_obj, "created_at", (double)created);
|
||||
|
||||
/* Extract endpoints from the advert. */
|
||||
if (advert) {
|
||||
cJSON *endpoints = cJSON_GetObjectItemCaseSensitive(advert, "endpoints");
|
||||
if (cJSON_IsArray(endpoints)) {
|
||||
cJSON_AddItemToObject(node_obj, "endpoints",
|
||||
cJSON_Duplicate(endpoints, TRUE));
|
||||
}
|
||||
cJSON *sig_relays = cJSON_GetObjectItemCaseSensitive(advert, "signalRelays");
|
||||
if (cJSON_IsArray(sig_relays)) {
|
||||
cJSON_AddItemToObject(node_obj, "signal_relays",
|
||||
cJSON_Duplicate(sig_relays, TRUE));
|
||||
}
|
||||
cJSON_Delete(advert);
|
||||
}
|
||||
|
||||
/* Flag direct peer. */
|
||||
gboolean is_direct = FALSE;
|
||||
cJSON *d;
|
||||
cJSON_ArrayForEach(d, direct_npubs) {
|
||||
if (cJSON_IsString(d) &&
|
||||
strcmp(d->valuestring, npub) == 0) {
|
||||
is_direct = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cJSON_AddBoolToObject(node_obj, "is_direct_peer", is_direct);
|
||||
|
||||
cJSON_AddItemToObject(map, npub, cJSON_Duplicate(node_obj, TRUE));
|
||||
cJSON_AddItemToArray(nodes, node_obj);
|
||||
}
|
||||
cJSON_Delete(map);
|
||||
for (int i = 0; i < result_count; i++) if (results[i]) cJSON_Delete(results[i]);
|
||||
free(results);
|
||||
}
|
||||
cJSON_Delete(direct_npubs);
|
||||
|
||||
cJSON_AddItemToObject(root, "nodes", nodes);
|
||||
cJSON_AddNumberToObject(root, "count", cJSON_GetArraySize(nodes));
|
||||
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
respond_json(request, json);
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Handle sovereign://bookmarks/list — return all bookmark directories
|
||||
* and their bookmarks as JSON. Used by www/bookmarks.js. */
|
||||
* and their bookmarks as JSON. Used by www/bookmarks.js. */
|
||||
static void handle_bookmarks_list_json(WebKitURISchemeRequest *request) {
|
||||
int dir_count = 0;
|
||||
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
|
||||
@@ -3602,6 +4269,61 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://fips — FIPS mesh management page. */
|
||||
if (strcmp(uri, "sovereign://fips") == 0 ||
|
||||
strncmp(uri, "sovereign://fips?", 17) == 0) {
|
||||
serve_embedded_file(request, "fips.html");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.css") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
|
||||
serve_embedded_file(request, "fips.css");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips.js") == 0 ||
|
||||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
|
||||
serve_embedded_file(request, "fips.js");
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/status") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/status?", 24) == 0) {
|
||||
handle_fips_status_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/peers?", 23) == 0) {
|
||||
handle_fips_peers_json(request);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/connect?", 25) == 0) {
|
||||
handle_fips_connect(request, uri + 25);
|
||||
return;
|
||||
}
|
||||
if (strncmp(uri, "sovereign://fips/disconnect?", 28) == 0) {
|
||||
handle_fips_disconnect(request, uri + 28);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/restart?", 25) == 0) {
|
||||
handle_fips_restart(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/network") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/network?", 25) == 0) {
|
||||
handle_fips_network_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
|
||||
handle_fips_tree_json(request);
|
||||
return;
|
||||
}
|
||||
if (strcmp(uri, "sovereign://fips/directory") == 0 ||
|
||||
strncmp(uri, "sovereign://fips/directory?", 27) == 0) {
|
||||
handle_fips_directory_json(request);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Route sovereign://nostr/<method> requests. */
|
||||
if (strncmp(uri, "sovereign://nostr/", 18) != 0) {
|
||||
respond_error_json(request, -1, "Unknown sovereign:// path");
|
||||
|
||||
+246
-71
@@ -54,6 +54,7 @@ extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
|
||||
extern void on_menu_fips(GtkMenuItem *item, gpointer data);
|
||||
|
||||
/* Key press handler defined in main.c — connected to each webview so
|
||||
* keyboard shortcuts are caught before WebKit consumes the event. */
|
||||
@@ -151,6 +152,10 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
static gboolean on_notebook_button_press(GtkWidget *widget,
|
||||
GdkEventButton *event,
|
||||
gpointer user_data);
|
||||
static void on_new_tab_clicked(GtkButton *btn, gpointer data);
|
||||
static void setup_notebook_action_widgets(GtkWidget *notebook,
|
||||
gboolean is_main);
|
||||
static void track_avatar_image(GtkWidget *image);
|
||||
static gboolean on_window_focus_in(GtkWidget *widget,
|
||||
GdkEventFocus *event,
|
||||
gpointer user_data);
|
||||
@@ -905,8 +910,66 @@ static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
|
||||
* If the navigation action is a link click with a target that would open
|
||||
* a new view (WEBKIT_NAVIGATION_TYPE_LINK_CLICKED with a non-current
|
||||
* browser action), we open a new tab and ignore the default decision.
|
||||
*
|
||||
* URL rewriting (fips://, nostr:, .onion → tor://) is deferred to an idle
|
||||
* callback. Calling webkit_web_view_load_uri() synchronously from inside
|
||||
* the decide-policy handler starts a new navigation while WebKit is still
|
||||
* processing the current policy decision — this re-entrancy crashes
|
||||
* WebKitGTK with a segfault, most reliably when the webview is freshly
|
||||
* created (e.g. a new tab opened for an onion link). By ignoring the
|
||||
* current decision first and scheduling the redirect for the next idle
|
||||
* tick, the policy decision completes cleanly before the new navigation
|
||||
* begins.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
WebKitWebView *webview;
|
||||
char *uri; /* URL to load via webkit_web_view_load_uri */
|
||||
char *html; /* HTML to load via webkit_web_view_load_html (mutually exclusive with uri) */
|
||||
char *base_uri; /* base URI for load_html */
|
||||
} deferred_nav_t;
|
||||
|
||||
static gboolean deferred_nav_idle(gpointer user_data) {
|
||||
deferred_nav_t *nav = (deferred_nav_t *)user_data;
|
||||
if (nav == NULL) return G_SOURCE_REMOVE;
|
||||
|
||||
/* The webview may have been destroyed between scheduling the idle and
|
||||
* running it (e.g. the user closed the tab). Guard with WEBKIT_IS_WEB_VIEW. */
|
||||
if (nav->webview && WEBKIT_IS_WEB_VIEW(nav->webview)) {
|
||||
if (nav->html != NULL) {
|
||||
webkit_web_view_load_html(nav->webview, nav->html,
|
||||
nav->base_uri ? nav->base_uri : "about:blank");
|
||||
} else if (nav->uri != NULL) {
|
||||
webkit_web_view_load_uri(nav->webview, nav->uri);
|
||||
}
|
||||
}
|
||||
|
||||
g_clear_object(&nav->webview);
|
||||
g_free(nav->uri);
|
||||
g_free(nav->html);
|
||||
g_free(nav->base_uri);
|
||||
g_free(nav);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* Schedule a load_uri on the next idle tick. Takes ownership of `uri`. */
|
||||
static void defer_load_uri(WebKitWebView *webview, char *uri) {
|
||||
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
|
||||
nav->webview = g_object_ref(webview);
|
||||
nav->uri = uri; /* takes ownership */
|
||||
g_idle_add(deferred_nav_idle, nav);
|
||||
}
|
||||
|
||||
/* Schedule a load_html on the next idle tick. Takes ownership of `html`
|
||||
* and `base_uri`. */
|
||||
static void defer_load_html(WebKitWebView *webview, char *html, char *base_uri) {
|
||||
deferred_nav_t *nav = g_new0(deferred_nav_t, 1);
|
||||
nav->webview = g_object_ref(webview);
|
||||
nav->html = html; /* takes ownership */
|
||||
nav->base_uri = base_uri; /* takes ownership */
|
||||
g_idle_add(deferred_nav_idle, nav);
|
||||
}
|
||||
|
||||
static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
WebKitPolicyDecision *decision,
|
||||
WebKitPolicyDecisionType type,
|
||||
@@ -931,9 +994,11 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
if (uri && strncmp(uri, "fips://", 7) == 0) {
|
||||
char *normalized = normalize_fips_url(uri);
|
||||
if (normalized) {
|
||||
webkit_web_view_load_uri(webview, normalized);
|
||||
g_free(normalized);
|
||||
/* Ignore the current decision first, then defer the redirect
|
||||
* to the next idle tick to avoid re-entrant navigation inside
|
||||
* the decide-policy handler (which segfaults WebKitGTK). */
|
||||
webkit_policy_decision_ignore(decision);
|
||||
defer_load_uri(webview, normalized); /* takes ownership */
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
@@ -944,19 +1009,19 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
strncmp(uri, "nostr://", 8) != 0) {
|
||||
nostr_entity_type_t entity_type = nostr_url_detect(uri);
|
||||
if (entity_type == NOSTR_ENTITY_NSEC) {
|
||||
webkit_web_view_load_html(webview,
|
||||
char *html = g_strdup(
|
||||
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>"
|
||||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
|
||||
"nostr://blocked-private-key");
|
||||
"<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>");
|
||||
char *base_uri = g_strdup("nostr://blocked-private-key");
|
||||
webkit_policy_decision_ignore(decision);
|
||||
defer_load_html(webview, html, base_uri); /* takes ownership */
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
char *normalized = nostr_url_normalize(uri);
|
||||
if (normalized) {
|
||||
webkit_web_view_load_uri(webview, normalized);
|
||||
g_free(normalized);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
defer_load_uri(webview, normalized); /* takes ownership */
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
@@ -967,9 +1032,8 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
const char *tor_rest = NULL;
|
||||
if (uri && uri_is_http_onion(uri, &tor_rest)) {
|
||||
char *tor_url = g_strdup_printf("tor://%s", tor_rest);
|
||||
webkit_web_view_load_uri(webview, tor_url);
|
||||
g_free(tor_url);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
defer_load_uri(webview, tor_url); /* takes ownership */
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -1120,6 +1184,13 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
|
||||
|
||||
/* New-tab button + avatar as notebook action widgets, same as the
|
||||
* main window. The new-tab button is wired to THIS notebook so tabs
|
||||
* open in this window, not the main window. is_main=FALSE so a
|
||||
* separate avatar image is created and tracked (the global g_avatar
|
||||
* stays pointing at the main window's image). */
|
||||
setup_notebook_action_widgets(notebook, FALSE);
|
||||
|
||||
/* Build a window-level GtkPaned: left = sidebar container, right =
|
||||
* notebook. The sidebar is per-window (not per-tab), so it persists
|
||||
* across tab switches within this window. Hidden by default. */
|
||||
@@ -2295,6 +2366,11 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
|
||||
G_CALLBACK(on_menu_agent), g_window);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent);
|
||||
|
||||
GtkWidget *item_fips_page = gtk_menu_item_new_with_label("FIPS Mesh…");
|
||||
g_signal_connect(item_fips_page, "activate",
|
||||
G_CALLBACK(on_menu_fips), g_window);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips_page);
|
||||
|
||||
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
|
||||
g_signal_connect(item_settings, "activate",
|
||||
G_CALLBACK(on_menu_settings), g_window);
|
||||
@@ -2650,6 +2726,50 @@ static tab_info_t *tab_create(const char *url) {
|
||||
|
||||
static GtkWidget *g_avatar = NULL;
|
||||
|
||||
/* List of all avatar GtkImage widgets across all windows. When the
|
||||
* avatar picture is downloaded, every image in this list is updated so
|
||||
* auxiliary windows show the same avatar as the main window. The main
|
||||
* window's g_avatar is also kept in this list. */
|
||||
static GSList *g_avatar_images = NULL;
|
||||
|
||||
/* Track all avatar images so they can all be updated when the picture
|
||||
* arrives. Adds the image to the global list. */
|
||||
static void track_avatar_image(GtkWidget *image) {
|
||||
if (image == NULL) return;
|
||||
g_avatar_images = g_slist_prepend(g_avatar_images, image);
|
||||
/* Sink a ref so the image stays alive for the list even if the
|
||||
* button is destroyed before the list is cleaned up. The list is
|
||||
* never freed during the app lifetime (avatars persist for the
|
||||
* whole session), so this is a small intentional leak that avoids
|
||||
* dangling pointers in the download callback. */
|
||||
g_object_ref_sink(G_OBJECT(image));
|
||||
}
|
||||
|
||||
/* Update every tracked avatar image with the given pixbuf. Used by the
|
||||
* avatar download idle callback so all windows' avatars stay in sync. */
|
||||
static void set_all_avatar_pixbufs(GdkPixbuf *pixbuf) {
|
||||
for (GSList *l = g_avatar_images; l != NULL; l = l->next) {
|
||||
GtkWidget *img = GTK_WIDGET(l->data);
|
||||
if (img && GTK_IS_IMAGE(img) && pixbuf) {
|
||||
/* Each image needs its own pixbuf reference. */
|
||||
GdkPixbuf *copy = g_object_ref(pixbuf);
|
||||
gtk_image_set_from_pixbuf(GTK_IMAGE(img), copy);
|
||||
g_object_unref(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reset every tracked avatar image to the default icon. */
|
||||
static void set_all_avatar_icons(const char *icon_name) {
|
||||
for (GSList *l = g_avatar_images; l != NULL; l = l->next) {
|
||||
GtkWidget *img = GTK_WIDGET(l->data);
|
||||
if (img && GTK_IS_IMAGE(img)) {
|
||||
gtk_image_set_from_icon_name(GTK_IMAGE(img), icon_name,
|
||||
GTK_ICON_SIZE_BUTTON);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale a pixbuf to fill the given size, center-cropping to preserve
|
||||
* aspect ratio (like CSS object-fit: cover), then round the corners
|
||||
* to match the button's border-radius. Returns a new pixbuf (caller
|
||||
@@ -2718,11 +2838,12 @@ typedef struct {
|
||||
int size;
|
||||
} avatar_fetch_t;
|
||||
|
||||
/* Idle callback to set the avatar pixbuf on the widget. */
|
||||
/* Idle callback to set the avatar pixbuf on every tracked avatar image
|
||||
* (main window + all auxiliary windows) so they all stay in sync. */
|
||||
static gboolean avatar_set_pixbuf_idle(gpointer data) {
|
||||
GdkPixbuf *pixbuf = (GdkPixbuf *)data;
|
||||
if (g_avatar && pixbuf) {
|
||||
gtk_image_set_from_pixbuf(GTK_IMAGE(g_avatar), pixbuf);
|
||||
if (pixbuf) {
|
||||
set_all_avatar_pixbufs(pixbuf);
|
||||
g_object_unref(pixbuf);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
@@ -2776,20 +2897,14 @@ static gpointer avatar_fetch_thread_v2(gpointer data) {
|
||||
* Called from main.c after login. */
|
||||
void tab_manager_set_avatar(const char *pubkey_hex) {
|
||||
if (g_avatar == NULL || pubkey_hex == NULL || pubkey_hex[0] == '\0') {
|
||||
if (g_avatar) {
|
||||
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
|
||||
"avatar-default-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON);
|
||||
}
|
||||
set_all_avatar_icons("avatar-default-symbolic");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Query the kind 0 profile from SQLite. */
|
||||
cJSON *kind0 = db_get_latest_event(pubkey_hex, 0);
|
||||
if (kind0 == NULL) {
|
||||
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
|
||||
"avatar-default-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON);
|
||||
set_all_avatar_icons("avatar-default-symbolic");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2808,9 +2923,7 @@ void tab_manager_set_avatar(const char *pubkey_hex) {
|
||||
cJSON_Delete(kind0);
|
||||
|
||||
if (picture == NULL) {
|
||||
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
|
||||
"avatar-default-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON);
|
||||
set_all_avatar_icons("avatar-default-symbolic");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2842,8 +2955,81 @@ static void on_avatar_clicked(GtkButton *btn, gpointer data) {
|
||||
|
||||
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
|
||||
(void)btn;
|
||||
(void)data;
|
||||
tab_manager_new_tab(NULL);
|
||||
GtkWidget *nb = GTK_WIDGET(data);
|
||||
/* If a specific notebook was passed (the button sits in an auxiliary
|
||||
* window's tab strip), direct the new tab into that notebook by
|
||||
* setting g_target_notebook for the duration of the call. Otherwise
|
||||
* fall back to the active window's notebook. */
|
||||
if (nb != NULL) {
|
||||
g_target_notebook = nb;
|
||||
tab_manager_new_tab(NULL);
|
||||
g_target_notebook = NULL;
|
||||
} else {
|
||||
tab_manager_new_tab(NULL);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Notebook action-widget setup ─────────────────────────────────── *
|
||||
* Adds the new-tab button (right end) and avatar button (left end) as
|
||||
* GtkNotebook action widgets, plus the right-click context-menu handler.
|
||||
* Used by both the main window's notebook (tab_manager_init) and each
|
||||
* auxiliary window's notebook (tab_manager_new_window) so every window
|
||||
* has a working new-tab button that opens tabs in THAT window.
|
||||
*
|
||||
* notebook: the GtkNotebook to attach the action widgets to
|
||||
* is_main: TRUE for the main window — the avatar image is stored in
|
||||
* the global g_avatar (so tab_manager_set_avatar can find it
|
||||
* by legacy reference). For auxiliary windows, a separate
|
||||
* image is created and tracked in g_avatar_images so it
|
||||
* still gets updated when the picture arrives.
|
||||
*/
|
||||
static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main) {
|
||||
g_return_if_fail(notebook != NULL && GTK_IS_NOTEBOOK(notebook));
|
||||
|
||||
/* Right-click / middle-click context menu on the tab strip. */
|
||||
gtk_widget_add_events(notebook, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(notebook, "button-press-event",
|
||||
G_CALLBACK(on_notebook_button_press), NULL);
|
||||
|
||||
/* New-tab button at the end of the tab strip. Pass the notebook as
|
||||
* user_data so the button opens the tab in THIS notebook, not just
|
||||
* whatever window happens to be active. */
|
||||
GtkWidget *new_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_image(GTK_BUTTON(new_btn),
|
||||
gtk_image_new_from_icon_name("tab-new-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON));
|
||||
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
|
||||
g_signal_connect(new_btn, "clicked",
|
||||
G_CALLBACK(on_new_tab_clicked), notebook);
|
||||
gtk_widget_show_all(new_btn);
|
||||
gtk_notebook_set_action_widget(GTK_NOTEBOOK(notebook), new_btn,
|
||||
GTK_PACK_END);
|
||||
|
||||
/* User avatar at the start (far left) of the tab strip. The main
|
||||
* window's image is stored in g_avatar for backward compatibility;
|
||||
* auxiliary windows get their own image, tracked in g_avatar_images
|
||||
* so all windows' avatars update together. */
|
||||
GtkWidget *avatar_img = gtk_image_new_from_icon_name(
|
||||
"avatar-default-symbolic", GTK_ICON_SIZE_BUTTON);
|
||||
if (is_main) {
|
||||
g_avatar = avatar_img;
|
||||
}
|
||||
track_avatar_image(avatar_img);
|
||||
|
||||
GtkWidget *avatar_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(avatar_btn), GTK_RELIEF_NORMAL);
|
||||
gtk_button_set_image(GTK_BUTTON(avatar_btn), avatar_img);
|
||||
gtk_widget_set_tooltip_text(avatar_btn, "Your profile");
|
||||
gtk_widget_set_valign(avatar_btn, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_margin_start(avatar_btn, 4);
|
||||
gtk_widget_set_name(avatar_btn, "avatar-btn");
|
||||
gtk_widget_set_size_request(avatar_btn, 28, 28); /* fixed square, matches hamburger */
|
||||
g_signal_connect(avatar_btn, "clicked",
|
||||
G_CALLBACK(on_avatar_clicked), NULL);
|
||||
gtk_widget_show_all(avatar_btn);
|
||||
gtk_notebook_set_action_widget(GTK_NOTEBOOK(notebook), avatar_btn,
|
||||
GTK_PACK_START);
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────────── */
|
||||
@@ -2861,48 +3047,8 @@ void tab_manager_init(GtkContainer *parent,
|
||||
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE);
|
||||
|
||||
/* Connect button-press on the notebook to handle right-click context
|
||||
* menus on tabs. This is more reliable than connecting to individual
|
||||
* tab label child widgets, because GtkNotebook intercepts button
|
||||
* presses on tabs for tab switching before they reach the label's
|
||||
* children. */
|
||||
gtk_widget_add_events(g_notebook, GDK_BUTTON_PRESS_MASK);
|
||||
g_signal_connect(g_notebook, "button-press-event",
|
||||
G_CALLBACK(on_notebook_button_press), NULL);
|
||||
|
||||
/* New-tab button as an action widget at the end of the tab strip. */
|
||||
GtkWidget *new_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
|
||||
gtk_button_set_image(GTK_BUTTON(new_btn),
|
||||
gtk_image_new_from_icon_name("tab-new-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON));
|
||||
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
|
||||
g_signal_connect(new_btn, "clicked", G_CALLBACK(on_new_tab_clicked), NULL);
|
||||
gtk_widget_show_all(new_btn);
|
||||
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn,
|
||||
GTK_PACK_END);
|
||||
|
||||
/* User avatar as an action widget at the start (far left) of the
|
||||
* tab strip. Uses a GtkButton with GTK_RELIEF_NORMAL to match the
|
||||
* hamburger menu button's visible border. margin_start matches the
|
||||
* toolbar's left margin (4px) so the avatar's left edge aligns
|
||||
* horizontally with the hamburger button's left edge.
|
||||
* The inner GtkImage (g_avatar) is updated via tab_manager_set_avatar(). */
|
||||
g_avatar = gtk_image_new_from_icon_name("avatar-default-symbolic",
|
||||
GTK_ICON_SIZE_BUTTON);
|
||||
GtkWidget *avatar_btn = gtk_button_new();
|
||||
gtk_button_set_relief(GTK_BUTTON(avatar_btn), GTK_RELIEF_NORMAL);
|
||||
gtk_button_set_image(GTK_BUTTON(avatar_btn), g_avatar);
|
||||
gtk_widget_set_tooltip_text(avatar_btn, "Your profile");
|
||||
gtk_widget_set_valign(avatar_btn, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_margin_start(avatar_btn, 4);
|
||||
gtk_widget_set_name(avatar_btn, "avatar-btn");
|
||||
gtk_widget_set_size_request(avatar_btn, 28, 28); /* fixed square, matches hamburger */
|
||||
g_signal_connect(avatar_btn, "clicked",
|
||||
G_CALLBACK(on_avatar_clicked), NULL);
|
||||
gtk_widget_show_all(avatar_btn);
|
||||
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), avatar_btn,
|
||||
GTK_PACK_START);
|
||||
/* New-tab button + avatar as notebook action widgets. */
|
||||
setup_notebook_action_widgets(g_notebook, TRUE);
|
||||
|
||||
/* CSS: remove internal padding from both buttons so their contents
|
||||
* fill edge-to-edge. Force both to a fixed square size (28x28) and
|
||||
@@ -3128,6 +3274,14 @@ int tab_manager_count(void) {
|
||||
return g_tab_count;
|
||||
}
|
||||
|
||||
/* Return the main window's notebook widget. Used by main.c's
|
||||
* delete-event handler to identify which tabs belong to the main window
|
||||
* (vs auxiliary windows) when closing the main window but keeping aux
|
||||
* windows alive. */
|
||||
GtkWidget *tab_manager_get_main_notebook(void) {
|
||||
return g_notebook;
|
||||
}
|
||||
|
||||
void tab_manager_set_title(int index, const char *title) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
@@ -3198,10 +3352,31 @@ void tab_manager_open_in_new_window(int index) {
|
||||
if (index < 0 || index >= g_tab_count) return;
|
||||
tab_info_t *tab = g_tabs[index];
|
||||
if (tab == NULL) return;
|
||||
/* Open the tab's current URL in a new window. Pass the active
|
||||
* webview as the related view so the new window shares the
|
||||
* WebProcess. */
|
||||
tab_manager_new_window(tab->current_url, tab->webview);
|
||||
|
||||
/* Capture the URL before opening the new window — the original tab
|
||||
* will be closed below, which frees the tab_info_t. */
|
||||
char url_buf[TAB_URL_MAX];
|
||||
snprintf(url_buf, sizeof(url_buf), "%s", tab->current_url);
|
||||
|
||||
/* Open the URL in a new window. Pass the original tab's webview as
|
||||
* the related view so the new window shares the WebProcess. The new
|
||||
* window takes its own ref on the related view, so it's safe to
|
||||
* close the original tab afterwards. */
|
||||
GtkWidget *new_wv = tab_manager_new_window(url_buf, tab->webview);
|
||||
if (new_wv == NULL) {
|
||||
/* New window creation failed — keep the original tab open so the
|
||||
* user doesn't lose the page. */
|
||||
return;
|
||||
}
|
||||
|
||||
/* Close the original tab — "Open in New Window" moves the tab to a
|
||||
* new window rather than duplicating it. The index may have shifted
|
||||
* if tab_manager_new_window added tabs to the array, so re-resolve
|
||||
* the tab's current index by pointer. */
|
||||
int cur_index = tab_array_find(tab);
|
||||
if (cur_index >= 0) {
|
||||
tab_manager_close_tab(cur_index);
|
||||
}
|
||||
}
|
||||
|
||||
void tab_manager_new_window_blank(void) {
|
||||
|
||||
@@ -208,6 +208,14 @@ WebKitWebView *tab_manager_get_main_webview(void);
|
||||
*/
|
||||
gboolean tab_manager_sidebar_visible(void);
|
||||
|
||||
/*
|
||||
* Returns the main window's GtkNotebook widget. Used by main.c's
|
||||
* delete-event handler to identify which tabs belong to the main window
|
||||
* (vs auxiliary windows) when closing the main window but keeping aux
|
||||
* windows alive.
|
||||
*/
|
||||
GtkWidget *tab_manager_get_main_notebook(void);
|
||||
|
||||
/*
|
||||
* Re-hide the sidebar container after gtk_widget_show_all(window).
|
||||
* Call this in main.c after show_all so the sidebar (which is packed
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.35"
|
||||
#define SB_VERSION "v0.0.44"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 35
|
||||
#define SB_VERSION_PATCH 44
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
+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);
|
||||
@@ -118,6 +118,13 @@
|
||||
<button class="save-btn" onclick="save('search_engine')">Save</button></div>
|
||||
</div>
|
||||
|
||||
<h2>Network</h2>
|
||||
<div class="field">
|
||||
<div><div class="setting-name">FIPS Mesh Network</div>
|
||||
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
|
||||
<div><a class="btn" href="sovereign://fips">Open FIPS Page</a></div>
|
||||
</div>
|
||||
|
||||
<h2>Security</h2>
|
||||
|
||||
<div class="setting">
|
||||
|
||||
Reference in New Issue
Block a user