Compare commits

...
9 Commits
Author SHA1 Message Date
Laan Tungir 7d91a186d4 v0.0.44 - Move tab to new window on 'Open in New Window' (close original tab); keep app alive when main window closes if aux windows have tabs (delete-event handler) 2026-07-17 19:14:35 -04:00
Laan Tungir 6a8e51c812 v0.0.43 - Add new-tab button and avatar to auxiliary windows: extract setup_notebook_action_widgets helper, wire new-tab button to its own notebook, sync avatar across all windows 2026-07-17 19:01:12 -04:00
Laan Tungir 12f8b45015 v0.0.42 - Fix segfault on onion/fips/nostr URL redirect in new tab: defer webkit_web_view_load_uri to idle callback to avoid re-entrant navigation inside decide-policy handler 2026-07-17 18:35:36 -04:00
Laan Tungir b90ea0bb11 v0.0.41 - Fix install.sh: FIPS prebuilt binary deleted before copy (RETURN trap) — use stable mktemp file 2026-07-17 12:41:59 -04:00
Laan Tungir 5b949ec034 v0.0.40 - install.sh: download FIPS prebuilt tarball from GitHub releases by default (source build via --build-fips-from-source) 2026-07-17 12:39:22 -04:00
Laan Tungir 43ee97bc38 v0.0.39 - Fix install.sh: FIPS is a Rust project — use cargo build --release, install rustup if needed, add libclang-dev 2026-07-17 12:33:02 -04:00
Laan Tungir 3ac4d4f9dc v0.0.38 - Fix install.sh apt package names for Debian 13 (trixie t64 transition) 2026-07-17 12:11:36 -04:00
Laan Tungir b111fffeaa v0.0.37 - Fix install.sh: read confirmation from /dev/tty, fix color rendering in summary 2026-07-17 11:55:42 -04:00
Laan Tungir 7ca05baff1 v0.0.36 - Add Install section to README 2026-07-17 11:32:29 -04:00
19 changed files with 3021 additions and 123 deletions
+52
View File
@@ -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:
+1 -1
View File
@@ -1 +1 @@
0.0.35
0.0.44
+201 -45
View File
@@ -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."
+465
View File
@@ -0,0 +1,465 @@
# FIPS Management Page — `sovereign://fips`
## Goal
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
inside the browser. This is **not** part of the settings page — it's a
separate status + management page, like a network control panel.
The page lets the user:
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
tree state, ownership (managed vs attached), error messages.
2. **See connected peers** — list of peers with their npub, address,
transport, and connection state.
3. **Connect to a peer** — add a new peer by npub + address + transport.
4. **Disconnect a peer** — remove a peer connection.
5. **Restart the FIPS service** — stop + start the managed daemon, or
re-attach.
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
services, showing both on one "Network" page makes sense. Tor is
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
interactive management.
This makes `.fips` addresses actually usable: the user can add peers from
the browser UI instead of dropping to `fipsctl` on the command line.
---
## Current State
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
The browser already has a client:
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
connection to the socket.
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
`{"command":"show_status"}`, parses response into `fips_status_t`
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
tun_active).
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
`{"command":"show_peers"}`, returns the `data` field as a JSON string
(caller frees). **This is not currently called anywhere** — it exists
but is unused.
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
**Also unused.**
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
needs verification against the FIPS daemon's control protocol — see
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
`fips_control_disconnect_peer()` function.
### Net services layer (`src/net_services.c` / `src/net_services.h`)
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
`control_socket[512]`.
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
(managed) or 5s (attached) and updates the struct. It does **not** poll
`show_peers` — only the peer *count* is tracked.
- The service `state` field (`service_state_t`) tracks the lifecycle:
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
it disables then re-enables the service.
### sovereign:// page pattern (`src/nostr_bridge.c`)
The existing pages (settings, profile, bookmarks, agents) follow this
pattern:
1. **HTML page** served from an embedded `www/` file via
[`serve_embedded_file()`](src/nostr_bridge.c:132).
2. **CSS** served from `www/<name>.css`, linked via
`<link href="sovereign://fips.css">`.
3. **JS** served from `www/<name>.js`, loaded via
`<script src="sovereign://fips.js">`.
4. **JSON data endpoints**`sovereign://fips/status` returns JSON,
fetched by the JS on page load and on refresh.
5. **Action endpoints**`sovereign://fips/connect?npub=...&address=...&transport=...`
performs an action and returns JSON.
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
scheme handler doesn't support `fetch()` reliably for `sovereign://`
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
A `sovereignGet()` helper wraps XHR and returns a Promise.
Routing is in the main `bridge_handle_request()` function
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
---
## Design
### Page layout
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Status ──────────────────────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Node: npub1abc...xyz │ │
│ │ TUN: fips0 (active) │ │
│ │ Peers: 3 connected │ │
│ │ Tree: healthy │ │
│ │ Ownership: managed (spawned by browser) │ │
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
│ │ [Restart Service] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Peers ───────────────────────────────────────────────┐ │
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
│ │ npub: [npub1... ] │ │
│ │ address: [host:port ] │ │
│ │ transport: [udp ▼] │ │
│ │ [Connect] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Bootstrap: 100% │ │
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
│ │ Ownership: attached (system Tor) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Routing
| URI | Handler | Returns |
|-----|---------|---------|
| `sovereign://fips` | serve `www/fips.html` | HTML page |
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
| `sovereign://fips.js` | serve `www/fips.js` | JS |
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
### JSON endpoints
#### `sovereign://fips/status`
```json
{
"fips": {
"state": "ready",
"enabled": true,
"ownership": "managed",
"node_npub": "npub1abc...",
"peer_count": 3,
"tun_active": true,
"tun_name": "fips0",
"tree_state": "healthy",
"daemon_state": "running",
"control_socket": "~/.sovereign_browser/fips/control.sock",
"error": null
},
"tor": {
"state": "ready",
"enabled": true,
"ownership": "attached",
"bootstrap_progress": 100,
"socks_endpoint": "socks5://127.0.0.1:9050",
"circuit_info": "",
"error": null
}
}
```
When a service is `DISABLED` or `FAILED`, the fields are populated as
best as possible and `error` holds the failure message.
#### `sovereign://fips/peers`
Returns the raw `show_peers` data from the FIPS daemon (passed through
as-is, since the format is defined by FIPS):
```json
{
"peers": [
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
]
}
```
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
#### `sovereign://fips/connect`
Query params: `npub`, `address`, `transport` (optional, defaults to
`udp`).
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/disconnect`
Query params: `npub`.
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/restart`
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
`{"status": "ok"}` (the restart is async — the JS polls
`sovereign://fips/status` to see when it's ready again).
---
## Changes Required
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
```c
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size);
```
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
exact command name** against the FIPS daemon's control protocol — check
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
`disconnect_peer` or `remove_peer`, adjust accordingly.
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
New functions (following the existing handler pattern):
- `handle_fips_status_json(request)` — reads
`net_service_get_status(NET_SERVICE_FIPS)` and
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
the fields above, responds as JSON.
- `handle_fips_peers_json(request)` — opens a control connection to the
FIPS socket (from `status.fips.control_socket`), calls
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
responds as JSON. If FIPS is not ready, returns empty peers + error.
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
`transport` from query string, opens a control connection, calls
`fips_control_connect_peer()`, responds as JSON.
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
control connection, calls `fips_control_disconnect_peer()`, responds
as JSON.
- `handle_fips_restart(request)` — calls
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
**Control connection management:** Each action handler opens a fresh
control connection to the FIPS socket, sends the command, closes the
connection. This is simpler than reusing the `net_service_t`'s
`control_fd` (which is owned by the poll callback and may be in use).
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
line 3606), following the same pattern as the settings/bookmarks/agents
blocks:
```c
/* Route sovereign://fips — FIPS mesh management page. */
if (strcmp(uri, "sovereign://fips") == 0 ||
strncmp(uri, "sovereign://fips?", 18) == 0) {
serve_embedded_file(request, "fips.html");
return;
}
if (strcmp(uri, "sovereign://fips.css") == 0 ||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
serve_embedded_file(request, "fips.css");
return;
}
if (strcmp(uri, "sovereign://fips.js") == 0 ||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
serve_embedded_file(request, "fips.js");
return;
}
if (strcmp(uri, "sovereign://fips/status") == 0 ||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
handle_fips_status_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
handle_fips_peers_json(request);
return;
}
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
handle_fips_connect(request, uri + 24);
return;
}
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
handle_fips_disconnect(request, uri + 27);
return;
}
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
handle_fips_restart(request);
return;
}
```
### 4. Create `www/fips.html`
The HTML page. Links `fips.css` and `fips.js`. Structure:
- Status section (populated by JS from `sovereign://fips/status`)
- Peers table (populated by JS from `sovereign://fips/peers`)
- Add peer form (npub, address, transport dropdown, Connect button)
- Tor status section (read-only, from the same status JSON)
- Refresh button (re-fetches status + peers)
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
`sovereign-base.css` for theme variables, monospace font, dark theme.
### 5. Create `www/fips.css`
Page-specific styles. Import the base theme:
```css
@import "sovereign://sovereign-base.css";
```
Style the status cards, peers table, add-peer form. Match the existing
sovereign:// page aesthetic.
### 6. Create `www/fips.js`
The page logic:
```javascript
/* On load: fetch status + peers, populate DOM.
* Refresh button: re-fetch.
* Connect form: POST to sovereign://fips/connect, then refresh peers.
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
* Restart button: GET sovereign://fips/restart, then poll status.
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
*/
```
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
### 7. Add a link to the FIPS page from the settings page
In `www/settings.html` (or `www/settings.js`), add a link:
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
section. This makes the page discoverable.
### 8. No Makefile changes needed
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
sufficient — no Makefile edit required.
---
## Files
```
www/
├── fips.html # FIPS management page (new)
├── fips.css # Page styles (new)
├── fips.js # Page logic (new)
src/
├── nostr_bridge.c # Add FIPS route handlers + routing
├── fips_control.c # Add fips_control_disconnect_peer()
├── fips_control.h # Add disconnect_peer() declaration
```
---
## Implementation Phases
### Phase 1: Status page (read-only)
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
5. Show Tor status (read-only)
6. Auto-refresh every 5s
7. Add link from settings page
### Phase 2: Peer management (interactive)
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
5. Add peers table + add-peer form + disconnect buttons to the page
6. Test: connect to a known FIPS peer, verify it appears in the list,
verify `.fips` URLs to that peer resolve in the browser
### Phase 3: Service control
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
2. Add restart button to the page
3. Poll status during restart (state transitions: READY → STOPPING →
DISCOVERING → STARTING → READY)
4. Show error messages when state is FAILED
### Phase 4: Polish
1. Copy-to-clipboard for node npub
2. QR code for node npub (reuse `sovereign://qr?text=...`)
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
4. Transport auto-detect (try udp, then tcp)
5. Address book — save known peers in SQLite for re-connecting after
restart
---
## Testing
1. Build and start browser
2. Navigate to `sovereign://fips`
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
4. Verify Tor status shows correctly
5. Verify auto-refresh updates peer count if peers change
6. If FIPS is not running:
- Verify status shows "disabled" or "failed" with error message
- Verify peers list is empty with "FIPS not ready" message
7. Add a peer:
- Enter a known FIPS peer's npub + address + transport
- Click Connect
- Verify peer appears in the peers list
- Verify `http://<peer-npub>.fips/` loads in a tab
8. Disconnect a peer:
- Click the ✕ next to a peer
- Verify peer disappears from the list
9. Restart FIPS:
- Click Restart Service
- Verify state transitions through stopping → starting → ready
- Verify peers reconnect (if persistent) or need re-adding
---
## Open Questions
1. **FIPS disconnect command name** — is it `disconnect`,
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
control protocol docs or `fipsctl` source. The `connect` command is
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
assumed.
2. **Peer persistence** — when the browser restarts the managed FIPS
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
does the user need to re-add them every time? If non-persistent,
Phase 4's "address book" (saving peers in SQLite) becomes more
important.
3. **FIPS daemon control protocol docs** — the `show_peers` response
format is passed through as-is. We should verify the field names
(`npub`, `address`, `transport`, `state`) match what the JS expects.
If the format differs, the JS can adapt, but it's better to know
upfront.
+255
View File
@@ -0,0 +1,255 @@
# FIPS Network View — `sovereign://fips/network`
## Goal
Extend the FIPS management page with a "Network" view that shows the
**entire mesh** as the local node knows it — not just direct peers, but
every node the daemon has discovered via bloom-filter propagation and
Nostr discovery. This answers "what can you see on FIPS?" and "can you
get a view of the entire network?"
## What the FIPS daemon exposes
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
these read-only commands relevant to a network view:
| Command | What it returns | Network-view use |
|---------|----------------|------------------|
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
### Key insight: "the entire network" = identity cache + tree
The FIPS daemon does **not** have a "ask peer X what peers it has"
command. The mesh is observed through two mechanisms:
1. **Bloom filters** — each node broadcasts a bloom filter of node
addresses it knows about. The local node unions these to estimate
total mesh size (`estimated_mesh_size`) and caches individual node
identities it has resolved (`show_identity_cache`).
2. **Spanning tree** — each node declares a parent; the tree
coordinates encode paths to the root. `show_tree` gives the local
node's tree position and its neighbors' positions.
So "the entire network" as seen from this node is:
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
last-seen). This is the node list for the network view.
- **`show_tree`** → the local tree topology (who is my parent, who are
my children, what is the root, what is the depth).
- **`show_status.estimated_mesh_size`** → a single estimated total.
We **cannot** build a full graph of who-connects-to-whom from the local
node's control socket alone — that would require every node to expose
its peer list, and FIPS doesn't do that (bloom filters propagate
*existence*, not *adjacency*). What we can show is:
- A **node list** (identity cache) — "these are all the nodes in the
mesh that this node knows about."
- A **tree view** — "this is my position in the spanning tree, my
parent, my children, the root."
- A **mesh summary** — "estimated N nodes total, tree depth D."
## Design
### Page layout
Add a "Network" tab/section to `sovereign://fips` (or a separate
`sovereign://fips/network` page linked from the main FIPS page).
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
│ [Status] [Peers] [Network] [Tree] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
│ │ Estimated mesh size: 47 nodes │ │
│ │ Known nodes (cache): 23 │ │
│ │ Tree depth: 4 │ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our position: depth 2, parent npub1def... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
│ │ ... │ │
│ │ (● = direct peer, ○ = known via discovery only) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our parent: npub1def... (fips.v0l.io) │ │
│ │ Our children: │ │
│ │ npub1jkl... depth 3 distance 1 │ │
│ │ npub1mno... depth 3 distance 1 │ │
│ │ Tree peers (1-hop): │ │
│ │ npub1abc... depth 0 root distance 2 │ │
│ │ npub1def... depth 1 parent distance 1 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### New JSON endpoints
| URI | FIPS command | Returns |
|-----|-------------|---------|
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
#### `sovereign://fips/network`
Calls three FIPS commands on a fresh control connection and combines
the results:
```json
{
"summary": {
"estimated_mesh_size": 47,
"known_nodes": 23,
"max_cache_entries": 256,
"tree_depth": 4,
"is_root": false,
"root_npub": "npub1abc...",
"root_display_name": "laantungir",
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
"our_parent": "npub1def...",
"our_parent_display_name": "fips.v0l.io"
},
"nodes": [
{
"node_addr": "e3da8293...",
"npub": "npub1abc...",
"display_name": "laantungir",
"ipv6_addr": "fde3:da82:...",
"last_seen_ms": 1784309108403,
"age_ms": 2000,
"is_direct_peer": true
}
],
"tree": {
"root": "1b4788b7...",
"root_npub": "npub1abc...",
"is_root": false,
"depth": 2,
"parent": "9d1192e8...",
"parent_display_name": "fips.v0l.io",
"peers": [
{
"node_addr": "1b4788b7...",
"display_name": "laantungir",
"depth": 0,
"distance_to_us": 2
}
]
}
}
```
The `is_direct_peer` flag on each node is computed by cross-referencing
the identity cache entries against the `show_peers` npub list (so the
UI can mark direct peers vs. discovery-only nodes).
### Implementation
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
These follow the existing `fips_control_show_peers()` pattern — send
the command, return the `data` field as a JSON string (caller frees):
```c
char *fips_control_show_tree(int fd, char *error, size_t error_size);
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
```
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
Opens a control connection, calls `show_status`, `show_peers`,
`show_identity_cache`, and `show_tree`, then combines them into the
JSON above. The `is_direct_peer` flag is computed by building a set of
direct-peer npubs from `show_peers` and checking each identity-cache
entry against it.
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
```c
if (strcmp(uri, "sovereign://fips/network") == 0 ||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
handle_fips_network_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
handle_fips_tree_json(request);
return;
}
```
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
Add tab navigation (Status | Peers | Network | Tree) to the page. The
Network tab fetches `sovereign://fips/network` and renders:
- Mesh summary card (estimated size, known nodes, depth, root, our
position).
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
- Spanning tree card (root, parent, children, 1-hop peers with
distances).
The Tree tab fetches `sovereign://fips/tree` and renders a more
detailed tree view (coords paths, declaration sequence).
#### 5. Auto-refresh
The Network tab polls `sovereign://fips/network` every 5s (same as the
status poll).
## Limitations (what we cannot show)
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
Bloom filters propagate node *existence*, not *edges*. To build a
full graph, every node would need to expose its peer list (a future
FIPS protocol extension), or the browser would need to query each
node's control socket individually (not practical across the mesh).
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
and `age_ms`, so we can show staleness, but there's no event stream
for join/leave (would need a FIPS control subscription protocol).
## Files
```
src/
├── fips_control.c # Add show_tree(), show_identity_cache()
├── fips_control.h # Add declarations
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
www/
├── fips.html # Add Network + Tree tabs
├── fips.css # Tab + network-table styles
├── fips.js # Network + tree rendering, tab switching
```
## Implementation phases
### Phase 1: Network summary + known nodes
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
2. Add `handle_fips_network_json()` combining status + peers + identity cache
3. Add `sovereign://fips/network` route
4. Add "Network" tab to the page with mesh summary + known-nodes table
5. Mark direct peers with a badge
### Phase 2: Spanning tree view
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
2. Add `sovereign://fips/tree` route
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
4. Optional: simple ASCII/indented tree rendering
### Phase 3: Polish
1. Sort known nodes by last-seen (most recent first)
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
3. Click a node to see its detail (ipv6, age, whether direct peer)
4. Copy-to-clipboard for npub / ipv6
5. Optional: bloom-filter visualization (advanced/debug tab)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+8
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,193 @@
/* FIPS management page — sovereign://fips
* Imports the shared sovereign:// base theme, then adds FIPS-specific
* layout for status cards, the peers table, and the add-peer form. */
@import url("sovereign://sovereign-base.css");
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin: 10px 0 20px;
}
.card .actions {
margin-top: 12px;
}
.mono {
font-family: var(--font);
word-break: break-all;
}
.err-text {
color: var(--accent);
}
/* State indicator dot. */
.state-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
background: var(--muted);
}
.state-dot.ready { background: var(--primary); }
.state-dot.failed { background: var(--accent); }
.state-dot.starting,
.state-dot.stopping,
.state-dot.bootstrapping,
.state-dot.discovering,
.state-dot.attaching { background: var(--accent); animation: pulse 1s infinite; }
/* Peers table. */
#peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#peers-table th,
#peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#peers-table td.mono { word-break: break-all; }
.peer-state-connected { color: var(--primary); }
.peer-state-connecting,
.peer-state-disconnected { color: var(--muted); }
.peer-state-failed { color: var(--accent); }
#refresh-btn { margin-left: 12px; }
/* Tab navigation. */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Known-nodes / tree-peers tables. */
#nodes-table, #tree-peers-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#nodes-table th, #nodes-table td,
#tree-peers-table th, #tree-peers-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
#nodes-table th, #tree-peers-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
#nodes-table td.mono, #tree-peers-table td.mono { word-break: break-all; }
/* Direct-peer badge in the known-nodes table. */
.peer-badge {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--primary);
}
.peer-badge.indirect { background: var(--muted); }
/* Staleness coloring for last-seen. */
.last-seen-fresh { color: var(--primary); }
.last-seen-stale { color: var(--muted); }
.last-seen-old { color: var(--muted); font-style: italic; }
/* Tree role tags. */
.tree-role { font-size: 11px; padding: 1px 6px; border-radius: 3px;
border: 1px solid var(--border); }
.tree-role.root { color: var(--accent); border-color: var(--accent); }
.tree-role.parent { color: var(--primary); }
.tree-role.child { color: var(--muted); }
/* Directory tab — compact one-line rows, wider table, smaller font. */
body.wide { max-width: 1400px; }
#tab-directory { max-width: 100%; }
#dir-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
table-layout: fixed;
}
#dir-table th, #dir-table td {
text-align: left;
padding: 3px 8px;
border-bottom: 1px solid var(--border);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#dir-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
}
/* Column widths: badge | npub | endpoint | connect */
#dir-table th:nth-child(1), #dir-table td:nth-child(1) { width: 20px; }
#dir-table th:nth-child(3), #dir-table td:nth-child(3) { width: 35%; }
#dir-table th:nth-child(4), #dir-table td:nth-child(4) { width: 80px; }
#dir-table td.mono {
font-family: var(--font);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Pinned "this is you" row — highlighted border. */
#dir-table tr.dir-ours-row {
border: 2px solid var(--accent);
background: rgba(255, 0, 0, 0.05);
}
#dir-table tr.dir-ours-row td {
border-bottom: 2px solid var(--accent);
font-weight: bold;
}
.dir-ours-tag {
color: var(--accent);
font-size: 10px;
font-weight: bold;
border: 1px solid var(--accent);
padding: 1px 4px;
border-radius: 3px;
}
/* Connect as clickable text, not a button. */
.dir-connect-link {
color: var(--accent);
font-size: 11px;
cursor: pointer;
text-decoration: underline;
}
.dir-connect-link:hover { text-decoration: none; }
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FIPS Mesh Network</title>
<link rel="stylesheet" href="sovereign://fips.css">
</head>
<body>
<h1>FIPS Mesh Network <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="status">Status</button>
<button class="tab-btn" data-tab="peers">Peers</button>
<button class="tab-btn" data-tab="network">Network</button>
<button class="tab-btn" data-tab="tree">Tree</button>
<button class="tab-btn" data-tab="directory">Directory</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Status tab -->
<div class="tab-panel active" id="tab-status">
<h2>Status</h2>
<div class="card" id="fips-status">
<div class="info-row"><span>State</span><span id="fips-state"></span></div>
<div class="info-row"><span>Node</span><span id="fips-node" class="mono"></span></div>
<div class="info-row"><span>TUN</span><span id="fips-tun"></span></div>
<div class="info-row"><span>Peers</span><span id="fips-peers"></span></div>
<div class="info-row"><span>Tree</span><span id="fips-tree"></span></div>
<div class="info-row"><span>Ownership</span><span id="fips-ownership"></span></div>
<div class="info-row"><span>Control</span><span id="fips-control" class="mono"></span></div>
<div class="info-row" id="fips-error-row" style="display:none">
<span>Error</span><span id="fips-error" class="err-text"></span>
</div>
<div class="actions">
<button class="btn" id="restart-btn">Restart Service</button>
</div>
</div>
<h2>Tor Status <span class="note">(read-only)</span></h2>
<div class="card" id="tor-status">
<div class="info-row"><span>State</span><span id="tor-state"></span></div>
<div class="info-row"><span>Bootstrap</span><span id="tor-bootstrap"></span></div>
<div class="info-row"><span>SOCKS</span><span id="tor-socks" class="mono"></span></div>
<div class="info-row"><span>Ownership</span><span id="tor-ownership"></span></div>
<div class="info-row" id="tor-error-row" style="display:none">
<span>Error</span><span id="tor-error" class="err-text"></span>
</div>
</div>
</div>
<!-- Peers tab -->
<div class="tab-panel" id="tab-peers">
<h2>Peers</h2>
<div id="peers-container">
<p class="empty" id="peers-empty">Loading peers…</p>
<table id="peers-table" style="display:none">
<thead>
<tr>
<th>npub</th><th>address</th><th>transport</th><th>state</th><th></th>
</tr>
</thead>
<tbody id="peers-body"></tbody>
</table>
</div>
<h2>Add Peer</h2>
<div class="card">
<div class="form">
<input type="text" id="peer-npub" placeholder="npub1..." size="50">
<input type="text" id="peer-address" placeholder="host:port" size="30">
<select id="peer-transport">
<option value="udp">udp</option>
<option value="tcp">tcp</option>
</select>
<button class="btn" id="connect-btn">Connect</button>
</div>
</div>
</div>
<!-- Network tab -->
<div class="tab-panel" id="tab-network">
<h2>Mesh Summary</h2>
<div class="card" id="mesh-summary">
<div class="info-row"><span>Known nodes</span><span id="mesh-known"></span></div>
<div class="info-row"><span>Direct peers</span><span id="mesh-direct"></span></div>
<div class="info-row"><span>Tree depth</span><span id="mesh-depth"></span></div>
<div class="info-row"><span>Root</span><span id="mesh-root" class="mono"></span></div>
<div class="info-row"><span>Our parent</span><span id="mesh-parent"></span></div>
<div class="info-row" id="mesh-error-row" style="display:none">
<span>Error</span><span id="mesh-error" class="err-text"></span>
</div>
</div>
<h2>Known Nodes <span class="note" id="nodes-count-label"></span></h2>
<div id="nodes-container">
<p class="empty" id="nodes-empty">Loading network…</p>
<table id="nodes-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>name</th><th>ipv6</th><th>last seen</th>
</tr>
</thead>
<tbody id="nodes-body"></tbody>
</table>
</div>
</div>
<!-- Directory tab -->
<div class="tab-panel" id="tab-directory">
<h2>FIPS Node Directory <span class="note" id="dir-count-label"></span></h2>
<p class="note">All nodes that have published a FIPS service advert (Kind 37195) to the Nostr advert relays. This is the global network directory, like join.fips.network.</p>
<div id="dir-container">
<p class="empty" id="dir-empty">Click to load the directory from Nostr relays…</p>
<table id="dir-table" style="display:none">
<thead>
<tr>
<th></th><th>npub</th><th>endpoint</th><th></th>
</tr>
</thead>
<tbody id="dir-body"></tbody>
</table>
</div>
<div class="actions" id="dir-actions" style="display:none">
<button class="btn" id="dir-refresh-btn">Reload Directory</button>
</div>
</div>
<!-- Tree tab -->
<div class="tab-panel" id="tab-tree">
<h2>Spanning Tree</h2>
<div class="card" id="tree-summary">
<div class="info-row"><span>Root</span><span id="tree-root" class="mono"></span></div>
<div class="info-row"><span>Is root</span><span id="tree-is-root"></span></div>
<div class="info-row"><span>Our depth</span><span id="tree-depth"></span></div>
<div class="info-row"><span>Our parent</span><span id="tree-parent"></span></div>
<div class="info-row"><span>Our node addr</span><span id="tree-node-addr" class="mono"></span></div>
<div class="info-row" id="tree-error-row" style="display:none">
<span>Error</span><span id="tree-error" class="err-text"></span>
</div>
</div>
<h2>Tree Peers <span class="note">(1-hop neighbors)</span></h2>
<div id="tree-peers-container">
<p class="empty" id="tree-peers-empty">Loading tree…</p>
<table id="tree-peers-table" style="display:none">
<thead>
<tr>
<th>node addr</th><th>name</th><th>depth</th><th>distance</th><th>role</th>
</tr>
</thead>
<tbody id="tree-peers-body"></tbody>
</table>
</div>
</div>
<script src="sovereign://fips.js"></script>
</body>
</html>
+564
View File
@@ -0,0 +1,564 @@
/* FIPS management page — sovereign://fips
*
* Tabs: Status | Peers | Network | Tree
*
* - Status: FIPS + Tor status cards (sovereign://fips/status)
* - Peers: direct peers table + add-peer form (sovereign://fips/peers)
* - Network: mesh summary + known-nodes table (sovereign://fips/network)
* - Tree: spanning tree view (sovereign://fips/tree)
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function() {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function() { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
function stateDotClass(state) {
return 'state-dot ' + (state || '');
}
/* Global: our own FIPS node npub (from show_status), used to pin our
* node in the Directory tab. */
var gOurNpub = '';
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function(panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
/* Widen the page for the directory tab (long npub list needs width). */
document.body.classList.toggle('wide', tabName === 'directory');
/* Load tab data on demand. */
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
if (tabName === 'directory') fetchDirectory();
}
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
});
/* ── Status tab ─────────────────────────────────────────────────── */
function renderFipsStatus(data) {
var f = data.fips || {};
var state = f.state || 'disabled';
/* Store our npub for the Directory tab pinning. */
gOurNpub = f.node_npub || '';
document.getElementById('fips-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('fips-node').textContent = f.node_npub || '—';
var tun = '—';
if (f.tun_name && f.tun_name.length) {
tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)');
}
document.getElementById('fips-tun').textContent = tun;
document.getElementById('fips-peers').textContent =
(f.peer_count != null ? f.peer_count : '—') + ' connected';
document.getElementById('fips-tree').textContent = f.tree_state || '—';
document.getElementById('fips-ownership').textContent =
f.ownership || '—';
document.getElementById('fips-control').textContent =
f.control_socket || '—';
var errRow = document.getElementById('fips-error-row');
var errEl = document.getElementById('fips-error');
if (f.error && f.error.length) {
errEl.textContent = f.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function renderTorStatus(data) {
var t = data.tor || {};
var state = t.state || 'disabled';
document.getElementById('tor-state').innerHTML =
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
document.getElementById('tor-bootstrap').textContent =
(t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%';
document.getElementById('tor-socks').textContent =
t.socks_endpoint || '—';
document.getElementById('tor-ownership').textContent =
t.ownership || '—';
var errRow = document.getElementById('tor-error-row');
var errEl = document.getElementById('tor-error');
if (t.error && t.error.length) {
errEl.textContent = t.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
}
function fetchStatus() {
return sovereignGet('sovereign://fips/status?_=' + Date.now())
.then(function(data) {
renderFipsStatus(data);
renderTorStatus(data);
})
.catch(function(e) {
showMsg('Failed to load status: ' + e.message, true);
});
}
/* ── Peers tab ──────────────────────────────────────────────────── */
function renderPeers(data) {
var body = document.getElementById('peers-body');
var table = document.getElementById('peers-table');
var empty = document.getElementById('peers-empty');
var peers = data.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'No peers (' + data.error + ')'
: 'No peers connected.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var html = '';
peers.forEach(function(p) {
var npub = p.npub || p.id || '';
var addr = p.transport_addr || p.address || p.addr || '';
var transport = p.transport_type || p.transport || '';
var state = p.connectivity || p.state || p.status || '';
var label = p.display_name || '';
html += '<tr>';
html += '<td class="mono">' + esc(npub) +
(label ? '<br><span class="note">' + esc(label) + '</span>' : '') +
'</td>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(transport) + '</td>';
html += '<td class="peer-state-' + esc(state) + '">' + esc(state) + '</td>';
html += '<td><button class="btn btn-del" data-npub="' + esc(npub) +
'" onclick="disconnectPeer(\'' + esc(npub).replace(/'/g, "\\'") +
'\')">✕</button></td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchPeers() {
return sovereignGet('sovereign://fips/peers?_=' + Date.now())
.then(function(data) { renderPeers(data); })
.catch(function(e) {
document.getElementById('peers-empty').textContent =
'Failed to load peers: ' + e.message;
});
}
function connectPeer() {
var npub = document.getElementById('peer-npub').value.trim();
var address = document.getElementById('peer-address').value.trim();
var transport = document.getElementById('peer-transport').value;
if (!npub || !address) {
showMsg('npub and address are required', true);
return;
}
var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) +
'&address=' + encodeURIComponent(address) +
'&transport=' + encodeURIComponent(transport);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Connect failed: ' + d.error, true);
} else {
showMsg('Peer added');
document.getElementById('peer-npub').value = '';
document.getElementById('peer-address').value = '';
fetchPeers();
}
})
.catch(function(e) { showMsg('Connect failed: ' + e.message, true); });
}
function disconnectPeer(npub) {
if (!npub) return;
if (!confirm('Disconnect peer ' + npub + '?')) return;
var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub);
sovereignGet(url)
.then(function(d) {
if (d.error) {
showMsg('Disconnect failed: ' + d.error, true);
} else {
showMsg('Peer disconnected');
fetchPeers();
}
})
.catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); });
}
function restartService() {
if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return;
showMsg('Restarting FIPS service…');
sovereignGet('sovereign://fips/restart')
.then(function(d) {
if (d.error) showMsg('Restart failed: ' + d.error, true);
else showMsg('Restart initiated');
})
.catch(function(e) { showMsg('Restart failed: ' + e.message, true); });
}
/* ── Network tab ────────────────────────────────────────────────── */
function formatAge(ageMs) {
if (ageMs == null) return '—';
var s = Math.floor(ageMs / 1000);
if (s < 60) return s + 's ago';
var m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
var h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
function ageClass(ageMs) {
if (ageMs == null) return '';
if (ageMs < 10000) return 'last-seen-fresh';
if (ageMs < 60000) return 'last-seen-stale';
return 'last-seen-old';
}
function renderNetwork(data) {
var s = data.summary || {};
var nodes = data.nodes || [];
/* Mesh summary. */
document.getElementById('mesh-known').textContent =
(s.known_nodes != null ? s.known_nodes : '—') +
(s.max_cache_entries != null ? ' / ' + s.max_cache_entries : '');
document.getElementById('mesh-direct').textContent =
(data.peer_count != null ? data.peer_count : '—');
document.getElementById('mesh-depth').textContent =
(s.tree_depth != null ? s.tree_depth : '—');
document.getElementById('mesh-root').textContent =
s.root_npub ? esc(s.root_npub) : '—';
document.getElementById('mesh-parent').textContent =
s.parent_display_name ? esc(s.parent_display_name) :
(s.is_root ? '(this node is root)' : '—');
var errRow = document.getElementById('mesh-error-row');
var errEl = document.getElementById('mesh-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
} else {
errRow.style.display = 'none';
}
/* Known nodes table. */
var body = document.getElementById('nodes-body');
var table = document.getElementById('nodes-table');
var empty = document.getElementById('nodes-empty');
var countLabel = document.getElementById('nodes-count-label');
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Network unavailable: ' + data.error
: 'No known nodes (FIPS may not be ready).';
return;
}
/* Sort by last_seen descending (most recent first). */
nodes.sort(function(a, b) {
var aT = a.last_seen_ms || 0;
var bT = b.last_seen_ms || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var name = n.display_name || '—';
var ipv6 = n.ipv6_addr || '';
var age = n.age_ms != null ? n.age_ms : null;
var direct = n.is_direct_peer;
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'discovery only') + '"></span>';
html += '<tr>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td class="mono">' + esc(ipv6) + '</td>';
html += '<td class="' + ageClass(age) + '">' + formatAge(age) + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchNetwork() {
return sovereignGet('sovereign://fips/network?_=' + Date.now())
.then(function(data) { renderNetwork(data); })
.catch(function(e) {
document.getElementById('nodes-empty').textContent =
'Failed to load network: ' + e.message;
});
}
/* ── Tree tab ───────────────────────────────────────────────────── */
function renderTree(data) {
var t = data.tree || {};
var errRow = document.getElementById('tree-error-row');
var errEl = document.getElementById('tree-error');
if (data.error) {
errEl.textContent = data.error;
errRow.style.display = '';
document.getElementById('tree-peers-empty').textContent =
'Tree unavailable: ' + data.error;
document.getElementById('tree-peers-table').style.display = 'none';
/* Clear summary. */
['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr']
.forEach(function(id) { document.getElementById(id).textContent = '—'; });
return;
}
errRow.style.display = 'none';
document.getElementById('tree-root').textContent =
t.root_npub || t.root || '—';
document.getElementById('tree-is-root').textContent =
t.is_root ? 'yes' : 'no';
document.getElementById('tree-depth').textContent =
(t.depth != null ? t.depth : '—');
document.getElementById('tree-parent').textContent =
t.parent_display_name || t.parent || '—';
document.getElementById('tree-node-addr').textContent =
t.my_node_addr || '—';
/* Tree peers (1-hop neighbors). */
var body = document.getElementById('tree-peers-body');
var table = document.getElementById('tree-peers-table');
var empty = document.getElementById('tree-peers-empty');
var peers = t.peers || [];
if (!peers.length) {
table.style.display = 'none';
empty.style.display = '';
empty.textContent = 'No tree peers.';
return;
}
empty.style.display = 'none';
table.style.display = '';
var myAddr = t.my_node_addr || '';
var parentAddr = t.parent || '';
var html = '';
peers.forEach(function(p) {
var addr = p.node_addr || '';
var name = p.display_name || '—';
var depth = p.depth != null ? p.depth : '—';
var dist = p.distance_to_us != null ? p.distance_to_us : '—';
var role = '';
if (addr === parentAddr && !t.is_root) {
role = '<span class="tree-role parent">parent</span>';
} else if (addr === t.root) {
role = '<span class="tree-role root">root</span>';
} else {
role = '<span class="tree-role child">peer</span>';
}
html += '<tr>';
html += '<td class="mono">' + esc(addr) + '</td>';
html += '<td>' + esc(name) + '</td>';
html += '<td>' + esc(depth) + '</td>';
html += '<td>' + esc(dist) + '</td>';
html += '<td>' + role + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchTree() {
return sovereignGet('sovereign://fips/tree?_=' + Date.now())
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('tree-peers-empty').textContent =
'Failed to load tree: ' + e.message;
});
}
/* ── Directory tab ───────────────────────────────────────────────── */
function renderDirectory(data) {
var body = document.getElementById('dir-body');
var table = document.getElementById('dir-table');
var empty = document.getElementById('dir-empty');
var countLabel = document.getElementById('dir-count-label');
var actions = document.getElementById('dir-actions');
var nodes = data.nodes || [];
countLabel.textContent = '(' + nodes.length + ' nodes)';
if (!nodes.length) {
table.style.display = 'none';
actions.style.display = 'none';
empty.style.display = '';
empty.textContent = data.error
? 'Directory unavailable: ' + data.error
: 'No advertised nodes found on the advert relays.';
return;
}
/* Sort: our own node first (pinned), then by created_at descending. */
nodes.sort(function(a, b) {
var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0;
var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0;
if (aOurs !== bOurs) return bOurs - aOurs;
var aT = a.created_at || 0;
var bT = b.created_at || 0;
return bT - aT;
});
empty.style.display = 'none';
table.style.display = '';
actions.style.display = '';
var html = '';
nodes.forEach(function(n) {
var npub = n.npub || '';
var direct = n.is_direct_peer;
var isOurs = (npub && npub === gOurNpub);
var badge = '<span class="peer-badge' +
(direct ? '' : ' indirect') + '" title="' +
(direct ? 'direct peer' : 'not connected') + '"></span>';
/* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */
var endpointsStr = '—';
if (n.endpoints && n.endpoints.length) {
var ep = n.endpoints[0];
endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || '');
if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1);
}
/* Connect: clickable text, not a button. */
var firstEp = (n.endpoints && n.endpoints[0]) || {};
var addr = firstEp.addr || '';
var transport = firstEp.transport || 'udp';
var connectLink = '';
if (isOurs) {
connectLink = '<span class="dir-ours-tag">this is you</span>';
} else if (addr && !direct) {
connectLink = '<a class="dir-connect-link" href="javascript:void(0)" onclick="connectFromDirectory(\'' +
esc(npub).replace(/'/g, "\\'") + '\', \'' +
esc(addr).replace(/'/g, "\\'") + '\', \'' +
esc(transport) + '\')">connect</a>';
} else if (direct) {
connectLink = '<span class="note">connected</span>';
}
var rowClass = isOurs ? ' class="dir-ours-row"' : '';
html += '<tr' + rowClass + '>';
html += '<td>' + badge + '</td>';
html += '<td class="mono">' + esc(npub) + '</td>';
html += '<td class="mono">' + endpointsStr + '</td>';
html += '<td>' + connectLink + '</td>';
html += '</tr>';
});
body.innerHTML = html;
}
function fetchDirectory() {
var empty = document.getElementById('dir-empty');
empty.style.display = '';
empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…';
document.getElementById('dir-table').style.display = 'none';
document.getElementById('dir-actions').style.display = 'none';
return sovereignGet('sovereign://fips/directory?_=' + Date.now())
.then(function(data) { renderDirectory(data); })
.catch(function(e) {
empty.textContent = 'Failed to load directory: ' + e.message;
});
}
function connectFromDirectory(npub, address, transport) {
/* Switch to the Peers tab, pre-fill the add-peer form, and connect. */
switchTab('peers');
document.getElementById('peer-npub').value = npub;
document.getElementById('peer-address').value = address;
var sel = document.getElementById('peer-transport');
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === transport) {
sel.selectedIndex = i;
break;
}
}
connectPeer();
}
/* ── Refresh + auto-refresh ─────────────────────────────────────── */
function refresh() {
showMsg('');
var activeTab = document.querySelector('.tab-btn.active');
var tabName = activeTab ? activeTab.dataset.tab : 'status';
var promises = [fetchStatus(), fetchPeers()];
if (tabName === 'network') promises.push(fetchNetwork());
if (tabName === 'tree') promises.push(fetchTree());
Promise.all(promises).then(function() {});
}
document.getElementById('refresh-btn').addEventListener('click', refresh);
document.getElementById('connect-btn').addEventListener('click', connectPeer);
document.getElementById('restart-btn').addEventListener('click', restartService);
var dirRefreshBtn = document.getElementById('dir-refresh-btn');
if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory);
/* Initial load. */
refresh();
/* Auto-refresh status + peers every 5s. Network/tree refresh on
* tab activation and every 10s when active. */
setInterval(function() {
fetchStatus();
fetchPeers();
}, 5000);
setInterval(function() {
var activeTab = document.querySelector('.tab-btn.active');
if (!activeTab) return;
var tabName = activeTab.dataset.tab;
if (tabName === 'network') fetchNetwork();
if (tabName === 'tree') fetchTree();
}, 10000);
+7
View File
@@ -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">