Compare commits

...
12 Commits
Author SHA1 Message Date
Laan Tungir eb093ee16a v0.0.52 - Release: login dialog shows app version in title; n_signer key-index label live-updates BIP-32 derivation path 2026-07-20 06:55:37 -04:00
Laan Tungir 618d52ff69 v0.0.51 - Reduce terminal noise: suppress processes polling logs, remove perf-probe and inject success prints 2026-07-19 15:44:00 -04:00
Laan Tungir 3d32c64b0a v0.0.50 - CPU values: black text, red only for >=90%; remove all other heat colors and shaded backgrounds 2026-07-19 14:55:53 -04:00
Laan Tungir ca2907b357 v0.0.49 - Fix: WebKit caching sovereign:// responses — add cache-busting to processes polls; remove shaded backgrounds from CPU values; remove debug prints 2026-07-19 14:13:43 -04:00
Laan Tungir 0892a9c7e1 v0.0.48 - Fix: heat bar text color readable on all backgrounds (grey-on-grey fix) 2026-07-19 10:21:47 -04:00
Laan Tungir b3955e4231 v0.0.47 - Release: processes diagnostics window with per-tab CPU probe 2026-07-19 10:06:01 -04:00
Laan Tungir 3bacac4a42 Fix: prevent set -e from aborting before Gitea release creation; add status logging 2026-07-18 20:35:17 -04:00
Laan Tungir fe1112f480 v0.0.46 - Release: target=_blank links open in new tab instead of new window 2026-07-18 20:28:51 -04:00
Laan Tungir fa5dd93c6d v0.0.45 - Release: nested bookmark tree view with HMAC d tags, bookmarks toolbar, tarball cleanup trap, install next-steps fix 2026-07-18 17:51:54 -04:00
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
44 changed files with 7388 additions and 821 deletions
+7
View File
@@ -7,6 +7,13 @@ sovereign_browser
*.dll
build/
# release tarballs (created by increment_and_push.sh -r, cleaned up by trap)
*.tar.gz
# compiled test binaries
tests/test_bookmarks_tree
tests/test_nostr_url
# editor / OS
*.swp
.DS_Store
+1 -1
View File
@@ -21,7 +21,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c
# Web files embedded into the binary as C byte arrays.
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
+1 -1
View File
@@ -1 +1 @@
0.0.41
0.0.52
+28 -4
View File
@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# --- Cleanup trap -------------------------------------------------------
# Any temp files registered here are removed on exit, including when the
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
# leftover sovereign_browser-*.tar.gz files from accumulating in the
# project root when a release run is interrupted.
CLEANUP_FILES=()
cleanup_on_exit() {
for f in "${CLEANUP_FILES[@]}"; do
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
done
}
trap cleanup_on_exit EXIT INT TERM
register_cleanup() {
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
}
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
@@ -234,8 +250,8 @@ git_commit_and_push() {
fi
CURRENT_BRANCH=$(git branch --show-current)
git push origin "$CURRENT_BRANCH"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
}
# --- Release mode: build + Gitea release --------------------------------
@@ -354,16 +370,24 @@ if [[ "$RELEASE_MODE" == true ]]; then
local_tarball=""
local_tarball=$(create_source_tarball || true)
# Register the tarball for cleanup so it's removed on exit even if a
# later step fails or the script is interrupted. The trap also catches
# the case where create_gitea_release aborts via `set -e`.
register_cleanup "$local_tarball"
print_status "Creating Gitea release for $NEW_VERSION..."
release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
print_status "Uploading release assets (binary + tarball)..."
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
else
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
fi
# Clean up the tarball (the binary is gitignored already).
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
# The tarball is removed by the cleanup_on_exit trap registered above,
# so no manual rm is needed here. The binary is gitignored already.
print_success "Release flow completed: $NEW_VERSION"
else
+3 -2
View File
@@ -606,8 +606,9 @@ main() {
print_success "sovereign_browser installed."
cat >&2 <<EOF
${GREEN}[SUCCESS]${NC} Next steps:
Run: sovereign-browser start --login-method generate
Or: sovereign-browser start --login-method generate --url https://example.com
Run: sovereign-browser
Or: sovereign-browser --login-method generate
Or: sovereign-browser --login-method generate --url https://example.com
Data dir: ~/.sovereign_browser/
Logs: sovereign-browser log
Stop: sovereign-browser stop
+261
View File
@@ -0,0 +1,261 @@
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
## Goal
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
## NIP-51 Compliance + Privacy — How Nesting Works
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
```
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
```
### Event layout (one per folder)
```
{
"kind": 30003,
"pubkey": "<user pubkey hex>",
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
"content": "<NIP-44 encrypted JSON>",
...
}
```
The NIP-44 encrypted `content` plaintext is:
```json
{"path": "Work/Projects/Secret", "bookmarks": [
["bookmark", "https://example.com", "Example", 1690000000]
]}
```
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
### Tree reconstruction (client-side)
1. Fetch all `kind=30003` events for the user's pubkey.
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
4. Attach the decrypted `bookmarks` array to that leaf node.
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
### Why this is safe and nostr-ish
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
```mermaid
flowchart TD
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
B --> C["Read path from plaintext JSON"]
C --> D["Split path on / to get segments"]
D --> E["Build trie of folders via node_ensure_path"]
E --> F["Attach decrypted bookmarks to leaf node"]
F --> G["Render tree on sovereign://bookmarks page"]
G --> H["User adds/moves/renames/deletes"]
H --> I["Compute HMAC d tag for affected path(s)"]
I --> J["NIP-44 encrypt new content and publish kind 30003"]
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
```
---
## Implementation
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
#### New struct
```c
typedef struct bookmark_node {
char *name; /* last segment of path, e.g. "Secret" */
char *path; /* full path, e.g. "Work/Projects/Secret" */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
```
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
#### New / changed API
Keep the existing public functions working (they now operate on paths, not flat names):
- `bookmarks_add(const char *path, const char *url, const char *title)``path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
- `bookmarks_remove(const char *path, const char *url)`
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
#### Internal helpers
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
- `static void node_free(bookmark_node_t *node)` — recursive free.
- `static void compute_hmac_key(unsigned char out[32])``HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
- `static char *path_to_d_tag(const char *path)``HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
#### Loading
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
1. Read the `d` tag.
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
4. `node_ensure_path(root, path)`.
5. Load bookmarks into that leaf.
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
```json
{
"have_signer": true,
"tree": {
"name": "",
"path": "",
"bookmarks": [],
"children": [
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
]}
]
}
}
```
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
#### HTML
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
- Add a path breadcrumb / parent selector in the add form.
#### JS
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
- A bookmark list under the folder (collapsible).
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
#### CSS
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
- Caret uses a CSS triangle that rotates 90° when expanded.
- Indent `.tree-children` by `20px` per level.
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
#### Layout
```
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
[ ============ WebKitWebView ============ ]
```
#### Implementation
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
```c
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
```
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
#### Cross-tab refresh
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
### 6. History filter — [`src/history.c`](src/history.c:27)
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
### 7. Tests
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
- `node_ensure_path` creates intermediate nodes.
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
---
## Migration
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
## Files touched
| File | Change |
|------|--------|
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |
+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)
+295
View File
@@ -0,0 +1,295 @@
# Processes Window — Implementation Plan
## Goal
A `sovereign://processes` internal page that lets the user pinpoint which
open tab is consuming CPU and dig into *what that page is doing*. Primary
use case: diagnosing `~/lt/client`, which currently burns CPU across many
tabs.
## The core technical reality
The codebase deliberately **shares one WebProcess across tabs** — every
new tab/webview is created with `webkit_web_view_new_with_related_view()`
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
The plan therefore uses **two layers**:
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
the single-tab-per-process case and shows overall footprint.
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
is what actually pinpoints the offending tab *within* a shared
WebProcess and tells you what it is doing.
## Architecture
```mermaid
flowchart LR
subgraph BrowserProcess[sovereign_browser main PID]
ProcInfo[process_info.c reads /proc]
Bridge[nostr_bridge.c sovereign:// routes]
AgentServer[agent_server / MCP]
end
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
ProbeA[perf-probe.js in tab A]
ProbeB[perf-probe.js in tab B]
ProbeC[perf-probe.js in tab C]
end
ProbeA -->|sovereign://processes/probe-report POST| Bridge
ProbeB -->|sovereign://processes/probe-report POST| Bridge
ProbeC -->|sovereign://processes/probe-report POST| Bridge
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
Bridge -->|JSON| UI[www/processes.js renders tables]
Bridge -->|JSON| AgentServer
```
## Layer 1 — Process view (`src/process_info.{c,h}`)
### Process discovery
| Process | How found |
|---|---|
| Main (`sovereign_browser`) | `getpid()` |
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
### Per-process fields (all from `/proc`, no external deps)
| Field | Source |
|---|---|
| pid | — |
| name | `/proc/<pid>/comm` |
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
| ppid | `/proc/<pid>/stat` field 4 |
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
| rss_kb | `/proc/<pid>/status``VmRSS` |
| pss_kb | `/proc/<pid>/smaps_rollup``Pss` (fallback to RSS if unavailable) |
| uss_kb | `/proc/<pid>/smaps_rollup``Private_Clean` + `Private_Dirty` |
| vmpeak_kb | `/proc/<pid>/status``VmPeak` |
| vmswap_kb | `/proc/<pid>/status``VmSwap` |
| threads | count of entries in `/proc/<pid>/task/` |
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
| io_read_kb | `/proc/<pid>/io``read_bytes` / 1024 |
| io_write_kb | `/proc/<pid>/io``write_bytes` / 1024 |
| fd_count | count of entries in `/proc/<pid>/fd/` |
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
### CPU% computation
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
1. Read current `utime+stime` and `time(NULL)`.
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
3. Store current as prev.
Caller polls `sovereign://processes/list` every 1s; first call returns
0% (no baseline), second call onward is accurate. Same approach as
`top`/`htop`.
### API
```c
/* src/process_info.h */
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
```
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
Injected into every webview via `WebKitUserContentManager` (same pattern
as the existing `window.nostr` injection in
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
`WebKitUserScriptInjectAtDocumentStart`).
### Signals collected
| Signal | Source | What it tells you |
|---|---|---|
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
### Reporting
The probe batches a report every 1s and POSTs it to
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
route handler in `nostr_bridge.c` calls
`process_info_record_probe(N, body)`, which stores the latest probe per
tab index in a static array (sized to `tab_manager_count()`).
The probe must NOT run on `sovereign://` internal pages (settings, fips,
processes itself, agents) — skip injection when the URL scheme is
`sovereign://`. This avoids self-noise and recursion.
### Probe payload shape
```json
{
"tab_index": 3,
"cpu_busy_percent": 42.5,
"fps": 12,
"timer_count": 38,
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
"net_in_flight": 3,
"net_requests_last_sec": 8,
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
"heap_used_mb": 124.3,
"event_listener_count": 217,
"worker_count": 2,
"dom_node_count": 4821,
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
}
```
## Routes (added to `nostr_bridge.c`)
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
| Route | Handler | Returns |
|---|---|---|
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
## UI (`www/processes.{html,css,js}`)
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
### Tab 1 — Processes
Sortable table, default sort by `cpu_percent` desc:
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|---|---|---|---|---|---|---|---|---|
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
Click a WebKitWebProcess row → expands inline to list its hosted tabs
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
hot tab even within a shared process.
### Tab 2 — Tabs (the primary diagnostic view)
Sortable table, default sort by `cpu_busy_percent` desc:
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|---|---|---|---|---|---|---|---|---|---|
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
Click a tab row → **drill-down panel** opens below with:
- Long-task timeline (last 30s, sparkline)
- Top long-task sources (script URLs + total ms)
- Active timers list (code label, interval, count)
- Recent network requests (URL, type, count)
- Worker count, event listener count, heap trend
- Action buttons: **Reload tab**, **Suspend tab** (replace with
`about:blank` keeping URL), **Close tab**
### Styling
Reuse `sovereign-base.css` and the card/table styles from
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
(green→yellow→red by CPU%) for instant visual scan.
## Menu integration
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
(or opens a new tab if none active) — same pattern as
`open_fips_cb` at line 332.
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
`open_processes`.
## MCP tools (so an agent can self-diagnose)
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
[`agent_mcp.c`](../src/agent_mcp.c:1):
| Tool | Args | Returns |
|---|---|---|
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
and tell me what it's doing" — the agent calls `processes.tabs`, finds
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
drill-down, and reports the offending script/timer/endpoint.
## Build / packaging
- Add `src/process_info.c` to `Makefile` sources.
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
into the binary via `serve_embedded_file()`.
- Register the perf-probe user script in the shared
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
`main.c` near where `nostr_inject` is wired), with a URL filter that
excludes `sovereign://*`.
## Verification
1. `make`
2. `./browser.sh restart --login-method generate`
3. Open `sovereign://processes` — verify main + WebKit processes listed
with sane CPU%/RSS.
4. Open several tabs of a CPU-heavy page (e.g.
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
view ranks them by `cpu_busy_percent` and the drill-down shows
long-task sources.
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
identifiable and the drill-down shows which script/timer/endpoint is
responsible.
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
JSON shape matches the contract above.
## Out of scope (future work)
- Forcing process-per-tab via `WebKitWebsitePolicies` /
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
per-tab `/proc` attribution but breaks the existing
`related_view` sharing workaround. Tracked separately.
- Historical recording (save probe samples to SQLite for trend charts).
- Per-tab network throttling / CPU limiting (would need WebKit API
support that may not exist).
- Killing/suspending a tab's WebProcess specifically (currently only
per-tab reload/close, which is safe).
Binary file not shown.
+31 -7
View File
@@ -20,6 +20,7 @@
* We access it via extern functions that main.c provides.
*/
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly);
extern void app_clear_signer(void);
extern nostr_signer_t *app_get_signer(void);
@@ -131,13 +132,18 @@ static cJSON *login_local(cJSON *params) {
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
return make_error("DERIVE_FAILED", "Failed to derive public key");
}
char privkey_hex_out[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex_out[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
@@ -169,13 +175,18 @@ static cJSON *login_generate(cJSON *params) {
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
nsec[0] = '\0';
}
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
@@ -206,13 +217,18 @@ static cJSON *login_seed(cJSON *params) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
@@ -254,8 +270,8 @@ static cJSON *login_readonly(cJSON *params) {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* Read-only: no signer created. */
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
/* Read-only: no signer created. No privkey available. */
app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE);
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
@@ -290,7 +306,13 @@ static cJSON *login_nip46(cJSON *params) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
/* NIP-46: the client_privkey is a throwaway session key for the bunker
* protocol, not the user's identity key. The user's actual signing key
* lives on the remote bunker. We do NOT use it for bookmark HMAC d tags
* (the bunker signer handles NIP-44 content encryption; d-tag derivation
* would require the user's real privkey, which we don't have). Bookmarks
* can still be loaded from the db in read-only fashion. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
@@ -374,7 +396,9 @@ static cJSON *login_nsigner(cJSON *params) {
sigprocmask(SIG_SETMASK, &old_set, NULL);
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
/* n_signer: the privkey never leaves the hardware device, so we can't
* derive HMAC d tags client-side. Bookmarks fall back to read-only. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
+13
View File
@@ -574,6 +574,19 @@ const mcp_tool_def_t tool_defs[] = {
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
/* Process / per-tab diagnostics (sovereign://processes) */
{"processes.list",
"List all sovereign_browser processes (main, WebKit renderers with their hosted tabs, WebKit network/gpu/storage, managed Tor/FIPS) with CPU%, RSS/PSS/USS, threads, uptime, I/O, FD count, and service state. CPU% is from /proc utime+stime deltas — the first call returns 0 for all processes (no baseline); poll ~1s for accurate values. Use this to find which WebProcess is hot.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tabs",
"List all open tabs with their per-tab performance probe (cpu_busy_percent from long-task sum, fps, active timer count, net in-flight, heap used, event listener count, worker count, DOM node count) and WebProcess PID. Sort by cpu_busy_percent to find the tab hogging CPU within a shared WebProcess. Internal sovereign:// tabs have null probes.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tab_probe",
"Get the full drill-down probe for a single tab: long-task timeline (last 30s), top long-task sources (script URLs + total ms), top active timers (code + interval + count), top network endpoints, heap, workers, DOM nodes. Use after processes.tabs identifies a hot tab to find out WHAT it is doing.",
"{\"type\":\"object\",\"properties\":{\"tab_index\":{\"type\":\"integer\",\"description\":\"Tab index from processes.tabs\"}},\"required\":[\"tab_index\"]}"},
};
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
+25
View File
@@ -16,6 +16,7 @@
#include "settings.h"
#include "search.h"
#include "nostr_url.h"
#include "process_info.h"
#include <string.h>
#include <stdlib.h>
@@ -4275,6 +4276,30 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return agent_fs_tools_dispatch(tool_name, params);
}
/* ── Process / per-tab diagnostics (work before login) ────────── *
* These read /proc and the in-memory probe store; they don't touch
* Nostr or page content, so they're safe before login. */
if (strcmp(tool_name, "processes.list") == 0) {
cJSON *arr = process_info_get_processes_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tabs") == 0) {
cJSON *arr = process_info_get_tabs_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tab_probe") == 0) {
int idx = get_int_param(params, "tab_index", -1);
if (idx < 0) {
return make_error("MISSING_PARAM",
"Provide 'tab_index' (from processes.tabs)");
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
return make_error("BAD_INDEX", "Tab index out of range");
}
return make_success(obj);
}
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
+801 -299
View File
File diff suppressed because it is too large Load Diff
+87 -42
View File
@@ -1,9 +1,22 @@
/*
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
* and stored in the event's content field as a JSON tag array.
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
*
* Privacy design:
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
* 64-hex-char identifier. Relays learn nothing about folder names or
* structure (not even the folder count). Determinism preserves NIP-33
* replaceability: re-publishing the same path replaces the prior event.
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
* both use a random IV/nonce per call, which would break replaceability.
* - The real path and the bookmark list live inside the NIP-44 encrypted
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
* - The HMAC key is derived from the user's Nostr privkey:
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
* It is per-user, never published, and automatically available on every
* device the user logs in on.
*
* The bookmarks sync across all devices the user logs in on via the
* bootstrap relays.
@@ -22,10 +35,15 @@
extern "C" {
#endif
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
#define BOOKMARKS_MAX_PER_NODE 500
#define BOOKMARKS_NAME_MAX 128
#define BOOKMARKS_PATH_MAX 1024
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
* per application and can be rotated independently if ever needed. */
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
typedef struct {
char *url;
@@ -33,70 +51,97 @@ typedef struct {
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/* A node in the bookmark tree. The root node has name="" and path="".
* Each node corresponds to a folder path; bookmarks live at any node. */
typedef struct bookmark_node {
char *name; /* last path segment, "" for root */
char *path; /* full path, "" for root */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
/*
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
* database and decrypts them using the signer.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars)
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
* privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive
* the HMAC key for opaque d tags. NULL in read-only mode
* (existing events can still be loaded from the db, but
* no new events can be published).
*
* Returns 0 on success, -1 on error.
*/
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
int bookmarks_init(nostr_signer_t *signer,
const char *pubkey_hex,
const char *privkey_hex);
/* Free the in-memory bookmark list. Call at shutdown. */
/* Free the in-memory bookmark tree. Call at shutdown. */
void bookmarks_cleanup(void);
/* ── Read access ───────────────────────────────────────────────────── */
/* Get the list of directories (read-only). Returns a pointer to the
* internal array (valid until the next bookmarks operation). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get the root node of the bookmark tree (read-only). Returns NULL if
* bookmarks_init has not been called. The returned pointer is valid until
* the next bookmarks mutation. */
const bookmark_node_t *bookmarks_get_root(void);
/* Get a specific directory by name. Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* Find a node by path. Returns NULL if not found. */
const bookmark_node_t *bookmarks_find(const char *path);
/* ── Write access (requires signer) ────────────────────────────────── */
/* ── Write access (requires signer + privkey) ──────────────────────── */
/* Add a bookmark to a directory. If the directory doesn't exist, it's
* created. Updates the in-memory list, re-encrypts, publishes a new
* kind 30003 event for that directory, and stores in SQLite.
/* Add a bookmark to a folder path. If the folder (or any intermediate
* folder) doesn't exist, it is created. Updates the in-memory tree,
* re-encrypts, publishes a new kind 30003 event for that path, and stores
* in SQLite.
*
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
int bookmarks_add(const char *path, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
/* Remove a bookmark by URL from a folder path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
int bookmarks_remove(const char *path, const char *url);
/* Move a bookmark from one directory to another.
/* Move a bookmark from one folder to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
int bookmarks_move(const char *from_path, const char *url,
const char *to_path);
/* Create a new empty directory. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if directory already exists / error. */
int bookmarks_create_dir(const char *name);
/* Create a new empty folder at the given path. Parent folders are created
* if missing. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if the folder already exists / error. */
int bookmarks_create_dir(const char *path);
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
* to "General" before deletion. Publishes a kind 5 deletion event.
/* Rename a folder. Re-publishes the renamed node and every descendant with
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
* old d tags.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
int bookmarks_rename_dir(const char *old_path, const char *new_path);
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
* its descendants' bookmarks) are moved to "General" before deletion.
* Emits kind 5 deletion events for every d tag in the subtree.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *path, int move_to_general);
/* ── Change notification ───────────────────────────────────────────── */
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
* refresh every open tab. */
typedef void (*bookmarks_changed_cb)(void *user_data);
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
/* ── Internal (used by relay fetch) ────────────────────────────────── */
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called by bookmarks_init and after relay fetch. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Store a raw kind 30003 event in the database and decrypt its content
* into the in-memory list. Called by the relay fetch after login. */
* into the in-memory tree. Called by the relay fetch after login. */
int bookmarks_store_and_load_event(const void *event_cjson);
#ifdef __cplusplus
+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);
+2
View File
@@ -29,6 +29,8 @@ void history_add_titled(const char *url, const char *title) {
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
strncmp(url, "sovereign://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;
+36 -5
View File
@@ -12,6 +12,7 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include "version.h"
#include <string.h>
#include <stdlib.h>
@@ -111,6 +112,7 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx);
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
static void on_index_changed(GtkWidget *spin, gpointer user_data);
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
#endif
@@ -1016,17 +1018,24 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_signal_connect(transport_combo, "changed",
G_CALLBACK(on_transport_changed), box);
/* Nostr index spinner. */
/* Nostr index spinner. The label shows the derived BIP-32 path
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
* spin button value, so they can see which key they are selecting. */
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
/* Update the path label whenever the index value changes. */
g_signal_connect(index_spin, "value-changed",
G_CALLBACK(on_index_changed), index_label);
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
@@ -1040,9 +1049,23 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_object_set_data(G_OBJECT(box), "service-box", service_box);
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
g_object_set_data(G_OBJECT(box), "index-label", index_label);
return box;
}
/* Update the key-index path label when the spin button value changes.
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
* current N value substituted in. */
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
GtkWidget *label = GTK_WIDGET(user_data);
int idx = gtk_spin_button_get_value_as_int(sb);
char text[96];
snprintf(text, sizeof(text),
"Key Index (m/44'/1237'/%d'/0/0):", idx);
gtk_label_set_text(GTK_LABEL(label), text);
}
/* ── Serial device enumeration callback ───────────────────────── */
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
@@ -1135,12 +1158,20 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
ctx.result = result;
ctx.done = FALSE;
/* Title. */
/* Title — shows the application name and version. */
GtkWidget *title = gtk_label_new(NULL);
gtk_label_set_markup(GTK_LABEL(title),
"<span size='large' weight='bold'>Sign in with your Nostr key</span>");
{
char title_markup[128];
snprintf(title_markup, sizeof(title_markup),
"<span size='large' weight='bold'>Sovereign Browser %s</span>",
SB_VERSION);
gtk_label_set_markup(GTK_LABEL(title), title_markup);
}
gtk_box_pack_start(GTK_BOX(content), title, FALSE, FALSE, 8);
GtkWidget *subtitle = gtk_label_new("Sign in with your Nostr key");
gtk_box_pack_start(GTK_BOX(content), subtitle, FALSE, FALSE, 0);
/* Notebook with tabs for each login method. */
GtkWidget *notebook = gtk_notebook_new();
gtk_box_pack_start(GTK_BOX(content), notebook, TRUE, TRUE, 4);
+122 -26
View File
@@ -66,6 +66,7 @@
typedef struct {
nostr_signer_t *signer; /* NULL for read-only mode */
char pubkey_hex[65];
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
key_store_method_t method;
gboolean readonly; /* TRUE if no signing available */
} app_state_t;
@@ -81,6 +82,7 @@ static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
@@ -89,6 +91,12 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
if (privkey_hex && privkey_hex[0]) {
strncpy(g_state.privkey_hex, privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
} else {
g_state.privkey_hex[0] = '\0';
}
g_logged_in = TRUE;
/* Update modules that hold a signer reference. */
@@ -112,6 +120,7 @@ void app_clear_signer(void) {
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
@@ -272,45 +281,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
/* Hamburger-menu items for sovereign:// internal pages always open in
* a new tab so the user's current page is preserved. */
void on_menu_settings(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://settings");
} else {
/* No active tab — open a new one pointed at the settings page. */
tab_manager_new_tab("sovereign://settings");
}
tab_manager_new_tab("sovereign://settings");
}
void on_menu_profile(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://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
tab_manager_new_tab("sovereign://profile");
}
/* The hamburger-menu "Agent Setup…" item navigates the active tab to
* the sovereign://agents internal page, which renders the agent provider
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
* internal page in a new tab. It renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://agents");
}
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://agents");
} else {
/* No active tab — open a new one pointed at the agent page. */
tab_manager_new_tab("sovereign://agents");
}
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
* internal page in a new tab. It renders the FIPS mesh network
* status + management UI. */
void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://fips");
}
/* The hamburger-menu "Processes…" item opens the sovereign://processes
* internal page in a new tab. It renders the process list + per-tab
* performance diagnostics UI. */
void on_menu_processes(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://processes");
}
/* ---- Keyboard shortcuts --------------------------------------------- *
@@ -438,6 +447,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_OPEN_PROCESSES:
on_menu_processes(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
@@ -490,7 +503,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;
@@ -558,6 +647,10 @@ static int do_login(GtkWindow *parent) {
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
result.method == KEY_STORE_METHOD_NONE);
g_logged_in = TRUE;
@@ -746,6 +839,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);
@@ -850,7 +945,8 @@ int main(int argc, char **argv) {
* from SQLite and decrypts them. In no-login/read-only mode, the
* signer is NULL so bookmarks are read-only. */
bookmarks_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL,
g_state.privkey_hex[0] ? g_state.privkey_hex : NULL);
/* Initialize the NIP-78 settings sync module. In no-login/read-only
* mode, the signer is NULL so settings are local-only (not synced). */
+1022 -169
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -148,5 +148,4 @@ void nostr_inject_setup(WebKitWebView *webview) {
/* The manager takes ownership of the script. */
webkit_user_script_unref(script);
g_print("[inject] window.nostr shim installed\n");
}
+101
View File
@@ -0,0 +1,101 @@
/*
* perf_probe.c — per-tab performance probe injection
*
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
* and is embedded into the binary by embed_web_files.sh. This module
* wraps it with a per-tab preamble that injects a
* <meta name="sb-tab-index" content="N"> tag into the document before
* the probe runs, so the probe can identify which tab it is reporting
* for.
*
* The preamble also short-circuits on sovereign:// pages (the probe
* would be self-referential and noisy on internal pages). We can't
* filter by URL at injection time because WebKitUserScript is added
* once per webview and applies to all future loads, so the URL check
* happens at runtime inside the preamble.
*/
#include "perf_probe.h"
#include "embedded_web_content.h"
#include <string.h>
#include <stdio.h>
/* The preamble runs at document_start, before perf-probe.js. It:
* 1. Checks the current URL — if it's a sovereign:// internal page,
* sets a flag so the probe body no-ops.
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
* probe (which runs at document_start as well, but after this
* preamble in script-order) can read its tab index.
*
* The tab index is baked in at injection time as a literal. */
static char *build_preamble(int tab_index) {
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
char *p = g_malloc(512);
if (p == NULL) return NULL;
snprintf(p, 512,
"(function(){\n"
" 'use strict';\n"
" try {\n"
" var u = location.href || '';\n"
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
" window.__sbPerfProbeSkip = true;\n"
" } else {\n"
" window.__sbPerfProbeSkip = false;\n"
" window.__sbPerfTabIndex = %d;\n"
" }\n"
" } catch(e) {}\n"
"})();\n",
tab_index);
return p;
}
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
if (manager == NULL) {
g_printerr("[perf-probe] No user content manager — probe not injected\n");
return;
}
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
char *preamble = build_preamble(tab_index);
if (preamble == NULL) return;
WebKitUserScript *pre_script = webkit_user_script_new(
preamble,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL, /* allow list (NULL = all) */
NULL /* block list */
);
webkit_user_content_manager_add_script(manager, pre_script);
webkit_user_script_unref(pre_script);
g_free(preamble);
/* The probe body itself, from the embedded www/js/perf-probe.js. */
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
if (f == NULL) {
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
return;
}
/* webkit_user_script_new needs a NUL-terminated string. The embedded
* data is a byte array of known size; copy it into a NUL-terminated
* buffer. */
char *probe_js = g_malloc(f->size + 1);
if (probe_js == NULL) return;
memcpy(probe_js, f->data, f->size);
probe_js[f->size] = '\0';
WebKitUserScript *probe_script = webkit_user_script_new(
probe_js,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, probe_script);
webkit_user_script_unref(probe_script);
g_free(probe_js);
}
+36
View File
@@ -0,0 +1,36 @@
/*
* perf_probe.h — per-tab performance probe injection
*
* Injects www/js/perf-probe.js into a webview via
* WebKitUserContentManager, prefixed with a tiny preamble that sets
* the tab index (so the probe knows which tab it is reporting for).
*
* The probe is skipped on sovereign:// internal pages to avoid
* self-noise and recursion. Call once per webview at tab creation,
* after nostr_inject_setup().
*/
#ifndef PERF_PROBE_H
#define PERF_PROBE_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Inject the perf probe into the given webview, tagged with the given
* tab index. The index is baked into a preamble that runs before the
* probe script and sets window.__sbPerfTabIndex.
*
* Safe to call multiple times on the same webview (the probe guards
* against double-injection).
*/
void perf_probe_setup(WebKitWebView *webview, int tab_index);
#ifdef __cplusplus
}
#endif
#endif /* PERF_PROBE_H */
+558
View File
@@ -0,0 +1,558 @@
/*
* process_info.c — process and per-tab performance diagnostics
*
* See process_info.h for the two-layer design. This file implements:
*
* Layer 1 — /proc enumeration for the main PID, WebKit child PIDs
* (discovered via webkit_web_view_get_process_id() per tab
* plus a PPID scan for network/gpu/storage processes), and
* managed Tor/FIPS PIDs from net_services. CPU% is computed
* from utime+stime deltas between successive calls.
*
* Layer 2 — storage of the most recent perf-probe.js report per tab
* index, exposed to the UI and MCP tools.
*
* All /proc parsing is Linux-specific and uses only libc + glib.
*/
#include "process_info.h"
#include "tab_manager.h"
#include "net_services.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <dirent.h>
#include <ctype.h>
#include <sys/types.h>
#include <glib.h>
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
typedef struct {
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
} cpu_sample_t;
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
static cpu_sample_t *cpu_sample_get(guint pid) {
if (g_cpu_samples == NULL) {
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
NULL, g_free);
}
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
if (s == NULL) {
s = g_new0(cpu_sample_t, 1);
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
}
return s;
}
static long g_clk_tck = 0;
static long clk_tck(void) {
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
return g_clk_tck > 0 ? g_clk_tck : 100;
}
/* ── /proc readers ──────────────────────────────────────────────────── */
/* Read the first line of a /proc file into buf (NUL-terminated). */
static int read_proc_file(const char *path, char *buf, size_t buflen) {
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
size_t n = fread(buf, 1, buflen - 1, f);
buf[n] = '\0';
fclose(f);
return (int)n;
}
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
* (2) comm (3) state (4) ppid (14) utime (15) stime
* (22) starttime (in clock ticks since boot)
* comm is wrapped in parens and may contain spaces, so we parse by
* finding the last ')' and tokenizing after it. */
static int parse_stat(guint pid, char *state_out, size_t state_sz,
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
char buf[4096];
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
/* comm: between first '(' and last ')'. */
char *lp = strchr(buf, '(');
char *rp = strrchr(buf, ')');
if (lp == NULL || rp == NULL || rp <= lp) return -1;
size_t comm_len = (size_t)(rp - lp - 1);
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
memcpy(comm_out, lp + 1, comm_len);
comm_out[comm_len] = '\0';
/* The rest after ") " is space-separated fields starting at field 3. */
char *rest = rp + 2;
/* rest = "state ppid ... " — tokenize. */
char *save = NULL;
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
if (tok == NULL) return -1;
if (state_out) {
size_t sl = strlen(tok);
if (sl >= state_sz) sl = state_sz - 1;
memcpy(state_out, tok, sl);
state_out[sl] = '\0';
}
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
if (tok == NULL) return -1;
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
* majflt, cmajflt. Skip them. */
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
if (tok == NULL) return -1;
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
if (tok == NULL) return -1;
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
if (tok == NULL) return -1;
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
return 0;
}
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
* the integer value in KB, or -1 if not found. */
static long status_field_kb(guint pid, const char *field) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/status", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long val = -1;
size_t flen = strlen(field);
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, field, flen) == 0) {
/* e.g. "VmRSS: 12345 kB" */
char *p = line + flen;
while (*p == ' ' || *p == '\t') p++;
val = strtol(p, NULL, 10);
break;
}
}
fclose(f);
return val;
}
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
* Returns 0 on success. */
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long pss = -1, priv_clean = 0, priv_dirty = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "Pss:", 4) == 0) {
pss = strtol(line + 4, NULL, 10);
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
priv_clean = strtol(line + 14, NULL, 10);
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
priv_dirty = strtol(line + 14, NULL, 10);
}
}
fclose(f);
if (pss_kb) *pss_kb = pss;
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
return (pss < 0) ? -1 : 0;
}
/* Read /proc/<pid>/io fields. Returns 0 on success. */
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/io", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long rb = 0, wb = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "read_bytes:", 11) == 0) {
rb = strtoll(line + 11, NULL, 10);
} else if (strncmp(line, "write_bytes:", 12) == 0) {
wb = strtoll(line + 12, NULL, 10);
}
}
fclose(f);
if (read_bytes) *read_bytes = rb;
if (write_bytes) *write_bytes = wb;
return 0;
}
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
static void read_cmdline(guint pid, char *buf, size_t buflen) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
FILE *f = fopen(path, "r");
if (f == NULL) { buf[0] = '\0'; return; }
size_t n = fread(buf, 1, buflen - 1, f);
fclose(f);
if (n == 0) { buf[0] = '\0'; return; }
buf[n] = '\0';
/* Replace NUL separators with spaces. */
for (size_t i = 0; i < n; i++) {
if (buf[i] == '\0') buf[i] = ' ';
}
/* Trim trailing space. */
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
}
/* Count entries in a /proc directory (used for threads and fds). */
static int count_dir_entries(const char *path) {
DIR *d = opendir(path);
if (d == NULL) return -1;
int count = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.') continue;
count++;
}
closedir(d);
return count;
}
/* Read /proc/stat's btime (boot time in seconds since epoch). */
static time_t g_btime = 0;
static time_t boot_time(void) {
if (g_btime != 0) return g_btime;
FILE *f = fopen("/proc/stat", "r");
if (f == NULL) return 0;
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "btime", 5) == 0) {
g_btime = (time_t)strtoll(line + 5, NULL, 10);
break;
}
}
fclose(f);
return g_btime;
}
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
typedef struct {
cJSON *probe; /* most recent probe report (owned) */
time_t ts; /* when recorded */
} probe_slot_t;
/* Sparse array indexed by tab index. Grows as needed. */
static probe_slot_t *g_probes = NULL;
static int g_probe_cap = 0;
static void probe_ensure(int idx) {
if (idx < 0) return;
if (idx >= g_probe_cap) {
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
while (new_cap <= idx) new_cap *= 2;
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
if (arr == NULL) return;
for (int i = g_probe_cap; i < new_cap; i++) {
arr[i].probe = NULL;
arr[i].ts = 0;
}
g_probes = arr;
g_probe_cap = new_cap;
}
}
void process_info_record_probe(int tab_index, cJSON *probe) {
if (tab_index < 0 || probe == NULL) {
if (probe) cJSON_Delete(probe);
return;
}
probe_ensure(tab_index);
if (tab_index >= g_probe_cap) {
cJSON_Delete(probe);
return;
}
if (g_probes[tab_index].probe) {
cJSON_Delete(g_probes[tab_index].probe);
}
g_probes[tab_index].probe = probe; /* take ownership */
g_probes[tab_index].ts = time(NULL);
}
/* Return a *reference* (not a copy) to the stored probe for a tab, or
* NULL. Caller must NOT free. */
static cJSON *probe_get(int tab_index) {
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
return g_probes[tab_index].probe;
}
/* Deep-copy a probe (so the caller can free independently). */
static cJSON *probe_copy(int tab_index) {
cJSON *p = probe_get(tab_index);
if (p == NULL) return NULL;
char *s = cJSON_PrintUnformatted(p);
cJSON *copy = cJSON_Parse(s);
free(s);
return copy;
}
/* ── Process enumeration ────────────────────────────────────────────── */
/* Ownership tag for a discovered PID. */
typedef enum {
OWN_MAIN,
OWN_WEBKIT_RENDERER,
OWN_WEBKIT_NETWORK,
OWN_WEBKIT_GPU,
OWN_WEBKIT_STORAGE,
OWN_TOR,
OWN_FIPS,
OWN_OTHER
} proc_own_t;
/* Build a cJSON object for one process. `own` and `service_state`
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
* array (may be NULL) for renderers. */
static cJSON *build_proc_obj(guint pid, proc_own_t own,
const char *service_state,
cJSON *hosted_tabs) {
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) {
return NULL;
}
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
* resolution) instead of time(NULL) (1-second resolution) so the
* value updates on every poll even when the poll interval is ~1s. */
cpu_sample_t *s = cpu_sample_get(pid);
guint64 cur_jiffies = utime + stime;
gint64 now_us = g_get_monotonic_time();
double cpu_pct = 0.0;
if (s->prev_wall_us > 0) {
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
if (dt > 0) {
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
if (cpu_pct < 0) cpu_pct = 0;
}
}
s->prev_jiffies = cur_jiffies;
s->prev_wall_us = now_us;
long rss_kb = status_field_kb(pid, "VmRSS:");
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
long vmswap_kb = status_field_kb(pid, "VmSwap:");
long pss_kb = -1, uss_kb = -1;
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
long io_read = 0, io_write = 0;
parse_io(pid, &io_read, &io_write);
char task_path[64];
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
int threads = count_dir_entries(task_path);
if (threads < 0) threads = 0;
char fd_path[64];
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
int fds = count_dir_entries(fd_path);
if (fds < 0) fds = 0;
char cmdline[1024];
read_cmdline(pid, cmdline, sizeof(cmdline));
/* Uptime: starttime is in ticks since boot; btime is boot seconds
* since epoch. process_start = btime + starttime/clk. */
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
time_t uptime = time(NULL) - start_sec;
if (uptime < 0) uptime = 0;
const char *own_str = "other";
switch (own) {
case OWN_MAIN: own_str = "main"; break;
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
case OWN_TOR: own_str = "tor"; break;
case OWN_FIPS: own_str = "fips"; break;
default: own_str = "other"; break;
}
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "pid", (double)pid);
cJSON_AddStringToObject(o, "name", comm);
cJSON_AddStringToObject(o, "cmdline", cmdline);
cJSON_AddStringToObject(o, "state", state);
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
cJSON_AddNumberToObject(o, "threads", threads);
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
cJSON_AddNumberToObject(o, "fd_count", fds);
cJSON_AddStringToObject(o, "ownership", own_str);
if (service_state) {
cJSON_AddStringToObject(o, "service_state", service_state);
}
if (hosted_tabs) {
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
} else {
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
}
return o;
}
/* Map a net_service_type_t to a service_state string via net_services. */
static const char *service_state_for(net_service_type_t t) {
const net_service_t *s = net_service_get_status(t);
if (s == NULL) return NULL;
switch (s->state) {
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";
}
}
/* Scan /proc for child processes of main_pid whose comm starts with
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
* (WebKitStorageProcess) and adds it to the `out` array with the
* appropriate ownership tag.
*
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* cannot map individual tabs to specific renderer PIDs here. Per-tab
* CPU attribution is handled by the Layer 2 probe instead. Renderer
* rows therefore show an empty hosted_tabs array. */
static void scan_webkit_procs(guint main_pid, cJSON *out) {
DIR *d = opendir("/proc");
if (d == NULL) return;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!isdigit((unsigned char)e->d_name[0])) continue;
guint pid = (guint)strtoul(e->d_name, NULL, 10);
if (pid == 0 || pid == main_pid) continue;
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) continue;
if (ppid != main_pid) continue;
if (strncmp(comm, "WebKit", 6) != 0) continue;
proc_own_t own = OWN_OTHER;
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
else continue; /* unknown WebKit aux — skip */
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
if (obj) cJSON_AddItemToArray(out, obj);
}
closedir(d);
}
cJSON *process_info_get_processes_json(void) {
cJSON *arr = cJSON_CreateArray();
guint main_pid = (guint)getpid();
/* Main process. */
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
/* WebKit child processes (renderers, network, gpu, storage).
* Discovered via /proc PPID scan — WebKitGTK 4.1 doesn't expose
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
scan_webkit_procs(main_pid, arr);
/* Tor + FIPS managed subprocesses. */
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
service_state_for(NET_SERVICE_TOR), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
service_state_for(NET_SERVICE_FIPS), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
return arr;
}
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
cJSON *process_info_get_tabs_json(void) {
cJSON *arr = cJSON_CreateArray();
int n = tab_manager_count();
for (int i = 0; i < n; i++) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL) continue;
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "index", i);
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(o, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(o, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* report 0 here. The Processes tab lists renderer PIDs from
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
cJSON *probe = probe_copy(i);
if (probe) {
cJSON_AddItemToObject(o, "probe", probe);
} else {
cJSON_AddNullToObject(o, "probe");
}
cJSON_AddItemToArray(arr, o);
}
return arr;
}
cJSON *process_info_get_tab_probe_json(int tab_index) {
tab_info_t *tab = tab_manager_get(tab_index);
if (tab == NULL) return NULL;
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "index", tab_index);
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(root, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(root, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
cJSON *probe = probe_copy(tab_index);
if (probe) {
cJSON_AddItemToObject(root, "probe", probe);
} else {
cJSON_AddNullToObject(root, "probe");
}
return root;
}
+82
View File
@@ -0,0 +1,82 @@
/*
* process_info.h — process and per-tab performance diagnostics
*
* Provides the data backing for the sovereign://processes internal page.
* Two layers:
*
* Layer 1 — process view: enumerates the main browser PID, the WebKit
* child processes (renderer / network / gpu / storage) and
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
* service state. CPU% is computed from utime+stime deltas
* between successive calls (top-style).
*
* Layer 2 — per-tab probe: a JS user script (www/js/perf-probe.js)
* injected into every non-sovereign:// page reports a
* 1-second batch of long-task, timer, network, heap, and
* FPS samples via sovereign://processes/probe-report. The
* latest report per tab index is stored here and exposed to
* the UI and to MCP tools.
*
* All /proc parsing is Linux-specific and dependency-free.
*/
#ifndef PROCESS_INFO_H
#define PROCESS_INFO_H
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build the Layer 1 process list as a cJSON array (caller frees).
* Shape:
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
* "threads":12, "uptime_sec":..., "io_read_kb":...,
* "io_write_kb":..., "fd_count":..., "ownership":"main",
* "service_state":"ready" (Tor/FIPS only),
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
* (renderers only) }]
*
* The first call returns cpu_percent 0 for every process (no baseline);
* subsequent calls return accurate deltas. Caller should poll ~1s.
*/
cJSON *process_info_get_processes_json(void);
/*
* Build the Layer 2 tab list as a cJSON array (caller frees).
* Combines tab_manager state (index, title, url, WebProcess pid) with
* the most recent probe report for each tab (if any).
* Shape:
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
* "is_internal":true,
* "probe": { ...latest probe report, or null... } }]
*/
cJSON *process_info_get_tabs_json(void);
/*
* Build a drill-down JSON object for a single tab (caller frees).
* Returns the full latest probe report (including the long-task
* timeline and top sources) plus the tab's identity fields.
* Returns NULL if the tab index is out of range.
*/
cJSON *process_info_get_tab_probe_json(int tab_index);
/*
* Record a probe report received from a page's perf-probe.js.
* Takes ownership of `probe` (frees it). `tab_index` is the
* sovereign://processes/probe-report?tab_index=N query value.
* Stores a copy of the report fields; the timeline and top_sources
* arrays are preserved for the drill-down view.
*/
void process_info_record_probe(int tab_index, cJSON *probe);
#ifdef __cplusplus
}
#endif
#endif /* PROCESS_INFO_H */
+5
View File
@@ -78,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"open_settings", "Open settings",
"Open the sovereign://settings page", "<Control>comma"
},
[SHORTCUT_OPEN_PROCESSES] = {
"open_processes", "Open processes",
"Open the sovereign://processes diagnostics page",
"<Control><Shift>Escape"
},
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",
+1
View File
@@ -41,6 +41,7 @@ typedef enum {
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_OPEN_PROCESSES,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
+626 -196
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -29,6 +29,7 @@ typedef struct {
GtkWidget *title_label; /* child of tab_label */
GtkWidget *favicon; /* favicon image in tab_label */
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
@@ -208,6 +209,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.41"
#define SB_VERSION "v0.0.52"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 41
#define SB_VERSION_PATCH 52
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head><title>CPU Hog Test</title></head>
<body>
<h1>CPU Hog Test</h1>
<p>This page runs a busy loop in setInterval to generate long tasks.</p>
<pre id="out"></pre>
<script>
// Busy loop every 250ms — should show up as long tasks + high cpu_busy_percent.
setInterval(function busyLoop() {
var start = performance.now();
while (performance.now() - start < 120) { /* burn 120ms */ }
document.getElementById('out').textContent =
'tick at ' + new Date().toISOString();
}, 250);
// Also spin a few extra timers to populate the timer_top list.
setInterval(function pollStatus() {
fetch('/test.json').catch(function(){});
}, 1000);
setInterval(function pollStatus2() {
fetch('/test.json').catch(function(){});
}, 2000);
</script>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
/*
* test_bookmarks_tree.c unit tests for the bookmark tree/HMAC d-tag logic
*
* Tests the pure pieces that don't require a signer, database, or relay
* connection:
* - HMAC-SHA256 d-tag derivation is deterministic (same key + path same hex)
* - HMAC-SHA256 d-tag derivation is opaque (different paths uncorrelated)
* - is_hmac_d_tag correctly distinguishes 64-hex HMAC tags from legacy
* plaintext directory names
*
* The full trie operations (add/remove/move/rename/delete) require a signer
* and database, so they are exercised via the browser's MCP tools at runtime
* rather than in this unit test.
*
* Build (standalone, links against nostr_core_lib):
* cc -std=c99 -Wall -Wextra -g -I./nostr_core_lib \
* tests/test_bookmarks_tree.c \
* nostr_core_lib/libnostr_core_x64.a \
* -lsecp256k1 -lssl -lcrypto -lm -o tests/test_bookmarks_tree
* ./tests/test_bookmarks_tree
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../nostr_core_lib/nostr_core/nostr_core.h"
static int failures = 0;
static int passes = 0;
#define CHECK(cond, msg) do { \
if (cond) { passes++; } \
else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \
} while (0)
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */
#define LABEL "sovereign-browser/bookmarks-folder-id-v1"
/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */
static char *path_to_d_tag(const unsigned char *privkey, const char *path) {
unsigned char hmac_key[32];
if (nostr_hmac_sha256(privkey, 32,
(const unsigned char *)LABEL, strlen(LABEL),
hmac_key) != 0) {
return NULL;
}
unsigned char mac[32];
if (nostr_hmac_sha256(hmac_key, 32,
(const unsigned char *)path, strlen(path),
mac) != 0) {
return NULL;
}
char *hex = malloc(65);
if (hex == NULL) return NULL;
nostr_bytes_to_hex(mac, 32, hex);
hex[64] = '\0';
return hex;
}
/* Mirror of is_hmac_d_tag in src/bookmarks.c. */
static int is_hmac_d_tag(const char *s) {
if (s == NULL || strlen(s) != 64) return 0;
for (const char *p = s; *p; p++) {
if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f')))
return 0;
}
return 1;
}
int main(void) {
/* Use a fixed test privkey (32 bytes of 0x01..0x20). */
unsigned char privkey[32];
for (int i = 0; i < 32; i++) privkey[i] = (unsigned char)(i + 1);
/* ── Determinism: same path → same d tag ────────────────────────── */
char *d1 = path_to_d_tag(privkey, "Work/Projects/Secret");
char *d2 = path_to_d_tag(privkey, "Work/Projects/Secret");
CHECK(d1 != NULL, "path_to_d_tag returned NULL");
CHECK(d2 != NULL, "path_to_d_tag returned NULL (2nd call)");
CHECK(d1 && d2 && strcmp(d1, d2) == 0,
"Same path should produce the same d tag (determinism)");
free(d1);
free(d2);
/* ── Opaqueness: different paths → different d tags ─────────────── */
char *da = path_to_d_tag(privkey, "Work");
char *db = path_to_d_tag(privkey, "Work/Projects");
char *dc = path_to_d_tag(privkey, "Personal");
CHECK(da && db && strcmp(da, db) != 0,
"Different paths should produce different d tags (no prefix leakage)");
CHECK(da && dc && strcmp(da, dc) != 0,
"Different paths should produce different d tags (2)");
/* The d tag should NOT contain the plaintext path. */
CHECK(da && strstr(da, "Work") == NULL,
"d tag should not leak the plaintext path");
CHECK(db && strstr(db, "Work") == NULL,
"d tag should not leak the plaintext path (child)");
free(da);
free(db);
free(dc);
/* ── d tag is 64 lowercase hex chars ────────────────────────────── */
char *d = path_to_d_tag(privkey, "General");
CHECK(d != NULL, "path_to_d_tag returned NULL for General");
CHECK(d && strlen(d) == 64, "d tag should be 64 hex chars");
CHECK(d && is_hmac_d_tag(d), "d tag should pass is_hmac_d_tag");
free(d);
/* ── is_hmac_d_tag distinguishes legacy names ───────────────────── */
CHECK(is_hmac_d_tag("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"64 lowercase hex should be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag("General"),
"Legacy plaintext 'General' should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag("Work/Projects"),
"Legacy plaintext path with slash should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag(""),
"Empty string should NOT be recognized as an HMAC d tag");
CHECK(!is_hmac_d_tag(NULL),
"NULL should NOT be recognized as an HMAC d tag");
/* Uppercase hex is not produced by nostr_bytes_to_hex (lowercase), so
* treat it as non-HMAC (legacy). */
CHECK(!is_hmac_d_tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
"Uppercase hex should NOT be recognized as an HMAC d tag (we emit lowercase)");
/* ── Different privkeys → different d tags for the same path ────── */
unsigned char privkey2[32];
for (int i = 0; i < 32; i++) privkey2[i] = (unsigned char)(255 - i);
char *e1 = path_to_d_tag(privkey, "General");
char *e2 = path_to_d_tag(privkey2, "General");
CHECK(e1 && e2 && strcmp(e1, e2) != 0,
"Different privkeys should produce different d tags for the same path");
free(e1);
free(e2);
/* ── Empty path is allowed (root) ───────────────────────────────── */
char *root_d = path_to_d_tag(privkey, "");
CHECK(root_d != NULL, "path_to_d_tag should handle empty path");
CHECK(root_d && strlen(root_d) == 64,
"Empty path d tag should still be 64 hex chars");
free(root_d);
printf("bookmarks tree tests: %d passed, %d failed\n", passes, failures);
return failures == 0 ? 0 : 1;
}
+65 -2
View File
@@ -4,5 +4,68 @@
* sovereign://sovereign-base.css. */
@import url("sovereign://sovereign-base.css");
/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */
#bm-dir { min-width: 160px; }
/* ── Tree view ─────────────────────────────────────────────────────── */
.tree-node {
margin: 2px 0;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.caret {
display: inline-block;
width: 14px;
cursor: pointer;
user-select: none;
color: var(--primary);
font-size: 10px;
transition: transform 0.15s ease;
transform: rotate(0deg);
flex-shrink: 0;
}
.caret.expanded {
transform: rotate(90deg);
}
.folder-icon {
font-size: 16px;
flex-shrink: 0;
}
.folder-name {
font-weight: bold;
color: var(--primary);
flex-shrink: 0;
}
.folder-actions {
display: flex;
gap: 6px;
margin-left: auto;
flex-wrap: wrap;
}
/* Smaller buttons in folder actions so the row stays compact. */
.folder-actions .btn {
padding: 3px 10px;
font-size: 11px;
margin-left: 0;
}
.tree-children {
border-left: 1px dashed var(--border);
padding-left: 4px;
}
/* Reuse .bm / .bm-title / .bm-url / .bm-meta / .actions / .btn / .btn-del
* from the base theme for bookmark rows. */
#bm-path, #new-path { min-width: 200px; }
+4 -4
View File
@@ -19,17 +19,17 @@
<div class="form">
<input type="text" id="bm-url" placeholder="URL" size="40">
<input type="text" id="bm-title" placeholder="Title" size="25">
<select id="bm-dir"></select>
<input type="text" id="bm-path" placeholder="Folder path (e.g. Work/Projects)" size="25">
<button class="btn" onclick="addBookmark()">Add</button>
</div>
<div class="form">
<input type="text" id="new-dir" placeholder="New directory name" size="25">
<button class="btn" onclick="createDir()">Create Directory</button>
<input type="text" id="new-path" placeholder="New folder path (e.g. Work/Projects/Secret)" size="35">
<button class="btn" onclick="createDir()">Create Folder</button>
</div>
</div>
<h2>Bookmarks</h2>
<div id="bm-list"></div>
<div id="bm-tree"></div>
<script src="sovereign://bookmarks.js"></script>
</body>
+225 -58
View File
@@ -1,8 +1,13 @@
/* Bookmarks page — sovereign://bookmarks
*
* Fetches the bookmark directory list from sovereign://bookmarks/list
* (JSON) and renders it. Add/delete/create-dir use the existing
* sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints.
* Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and
* renders it as an expandable/collapsible tree. Folders may be nested
* arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename
* use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir}
* endpoints.
*
* Expand/collapse state is persisted in localStorage so the user's
* expanded folders survive page reloads.
*/
function sovereignGet(url) {
return new Promise(function(resolve, reject) {
@@ -24,75 +29,237 @@ function esc(s) {
return d.innerHTML;
}
function renderBookmarks(data) {
/* ── Expand/collapse state (persisted in localStorage) ─────────────── */
var EXPANDED_KEY = 'sovereign_bookmarks_expanded';
function getExpanded() {
try {
var raw = localStorage.getItem(EXPANDED_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) { return {}; }
}
function setExpanded(path, isExpanded) {
var state = getExpanded();
if (isExpanded) state[path] = true;
else delete state[path];
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); }
catch (e) { /* ignore quota errors */ }
}
/* ── Tree rendering ────────────────────────────────────────────────── */
function renderTree(data) {
var haveSigner = !!data.have_signer;
document.getElementById('readonly-note').style.display =
haveSigner ? 'none' : '';
document.getElementById('add-form').style.display =
haveSigner ? '' : 'none';
/* Populate the directory <select> for the add form. */
var sel = document.getElementById('bm-dir');
sel.innerHTML = '';
(data.dirs || []).forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name;
sel.appendChild(opt);
});
var c = document.getElementById('bm-list');
var dirs = data.dirs || [];
if (dirs.length === 0) {
var c = document.getElementById('bm-tree');
var tree = data.tree;
if (!tree || !tree.children || tree.children.length === 0) {
c.innerHTML =
'<p class="empty">No bookmarks yet. Click the bookmark button ' +
'in the toolbar to save a page.</p>';
return;
}
var html = '';
dirs.forEach(function(dir) {
html += '<div class="dir-header"><h3>' + esc(dir.name) +
' (' + dir.count + ')</h3>';
if (haveSigner && dir.name !== 'General') {
var enc = encodeURIComponent(dir.name);
html += ' <a class="btn btn-del" href="sovereign://bookmarks/deletedir?dir=' +
enc + '" onclick="return confirm(\'Delete directory "' +
esc(dir.name) + '"? Bookmarks will be moved to General.\')">Delete Dir</a>';
}
html += '</div>';
if (dir.count === 0) {
html += '<p class="empty">(empty)</p>';
} else {
(dir.bookmarks || []).forEach(function(bm) {
var urlEnc = encodeURIComponent(bm.url);
var dirEnc = encodeURIComponent(dir.name);
var title = bm.title && bm.title.length ? bm.title : '(untitled)';
html += '<div class="bm"><div>';
html += '<div class="bm-title">' + esc(title) + '</div>';
html += '<div><a class="bm-url" href="' + esc(bm.url) + '">' +
esc(bm.url) + '</a></div>';
html += '<div class="bm-meta">Added: ' + esc(bm.added) + '</div></div>';
html += '<div class="actions">';
html += '<a class="btn" href="' + esc(bm.url) + '">Open</a>';
if (haveSigner) {
html += ' <a class="btn btn-del" href="sovereign://bookmarks/delete?dir=' +
dirEnc + '&url=' + urlEnc +
'" onclick="return confirm(\'Delete this bookmark?\')">Delete</a>';
}
html += '</div></div>';
});
}
c.innerHTML = '';
(tree.children || []).forEach(function(child) {
c.appendChild(renderNode(child, haveSigner, 0));
});
c.innerHTML = html;
}
function renderNode(node, haveSigner, depth) {
/* A folder row + its bookmark list + its children (recursive). */
var expandedState = getExpanded();
var isExpanded = !!expandedState[node.path];
var wrap = document.createElement('div');
wrap.className = 'tree-node';
/* Folder row. */
var row = document.createElement('div');
row.className = 'folder-row';
var caret = document.createElement('span');
caret.className = 'caret' + (isExpanded ? ' expanded' : '');
caret.textContent = '\u25B6'; /* right-pointing triangle */
caret.onclick = function() {
var nowExpanded = caret.classList.toggle('expanded');
childList.style.display = nowExpanded ? '' : 'none';
setExpanded(node.path, nowExpanded);
};
row.appendChild(caret);
var icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = '\u{1F4C1}'; /* folder emoji */
row.appendChild(icon);
var name = document.createElement('span');
name.className = 'folder-name';
name.textContent = node.name + ' (' + node.count + ')';
row.appendChild(name);
/* Per-folder actions. */
var actions = document.createElement('span');
actions.className = 'folder-actions';
if (haveSigner) {
var addHere = document.createElement('a');
addHere.className = 'btn';
addHere.href = '#';
addHere.textContent = 'Add Here';
addHere.onclick = function(e) {
e.preventDefault();
document.getElementById('bm-path').value = node.path;
document.getElementById('bm-url').focus();
};
actions.appendChild(addHere);
var newSub = document.createElement('a');
newSub.className = 'btn';
newSub.href = '#';
newSub.textContent = 'New Subfolder';
newSub.onclick = function(e) {
e.preventDefault();
var sub = prompt('New subfolder name under "' + node.path + '/":');
if (sub && sub.trim()) {
var newPath = node.path + '/' + sub.trim();
location.href = 'sovereign://bookmarks/createdir?name=' +
encodeURIComponent(newPath);
}
};
actions.appendChild(newSub);
if (node.path !== 'General' && node.path !== 'Bookmarks Bar') {
var rename = document.createElement('a');
rename.className = 'btn';
rename.href = '#';
rename.textContent = 'Rename';
rename.onclick = function(e) {
e.preventDefault();
var newName = prompt('Rename folder "' + node.path + '" to:',
node.path);
if (newName && newName.trim() && newName.trim() !== node.path) {
location.href = 'sovereign://bookmarks/renamedir?old=' +
encodeURIComponent(node.path) + '&new=' +
encodeURIComponent(newName.trim());
}
};
actions.appendChild(rename);
var del = document.createElement('a');
del.className = 'btn btn-del';
del.href = '#';
del.textContent = 'Delete';
del.onclick = function(e) {
e.preventDefault();
if (confirm('Delete folder "' + node.path + '"? ' +
'Bookmarks will be moved to General.')) {
location.href = 'sovereign://bookmarks/deletedir?dir=' +
encodeURIComponent(node.path);
}
};
actions.appendChild(del);
}
}
row.appendChild(actions);
wrap.appendChild(row);
/* Child list: bookmarks + subfolders. */
var childList = document.createElement('div');
childList.className = 'tree-children';
childList.style.display = isExpanded ? '' : 'none';
childList.style.marginLeft = '20px';
/* Bookmarks in this folder. */
(node.bookmarks || []).forEach(function(bm) {
childList.appendChild(renderBookmark(bm, node, haveSigner));
});
/* Subfolders. */
(node.children || []).forEach(function(child) {
childList.appendChild(renderNode(child, haveSigner, depth + 1));
});
wrap.appendChild(childList);
return wrap;
}
function renderBookmark(bm, folder, haveSigner) {
var row = document.createElement('div');
row.className = 'bm';
var info = document.createElement('div');
var title = document.createElement('div');
title.className = 'bm-title';
title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)';
info.appendChild(title);
var urlLink = document.createElement('div');
var a = document.createElement('a');
a.className = 'bm-url';
a.href = bm.url;
a.textContent = bm.url;
urlLink.appendChild(a);
info.appendChild(urlLink);
var meta = document.createElement('div');
meta.className = 'bm-meta';
meta.textContent = 'Added: ' + bm.added;
info.appendChild(meta);
row.appendChild(info);
if (haveSigner) {
var actions = document.createElement('div');
actions.className = 'actions';
var moveBtn = document.createElement('a');
moveBtn.className = 'btn';
moveBtn.href = '#';
moveBtn.textContent = 'Move';
moveBtn.onclick = function(e) {
e.preventDefault();
var dest = prompt('Move bookmark to folder path:', folder.path);
if (dest && dest.trim() && dest.trim() !== folder.path) {
location.href = 'sovereign://bookmarks/move?from=' +
encodeURIComponent(folder.path) + '&to=' +
encodeURIComponent(dest.trim()) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(moveBtn);
var delBtn = document.createElement('a');
delBtn.className = 'btn btn-del';
delBtn.href = '#';
delBtn.textContent = 'Delete';
delBtn.onclick = function(e) {
e.preventDefault();
if (confirm('Delete this bookmark?')) {
location.href = 'sovereign://bookmarks/delete?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url);
}
};
actions.appendChild(delBtn);
row.appendChild(actions);
}
return row;
}
/* ── Fetch + actions ───────────────────────────────────────────────── */
function fetchBookmarks() {
sovereignGet('sovereign://bookmarks/list?_=' + Date.now())
.then(function(data) { renderBookmarks(data); })
.then(function(data) { renderTree(data); })
.catch(function(e) {
document.getElementById('bm-list').innerHTML =
document.getElementById('bm-tree').innerHTML =
'<p class="empty">Failed to load bookmarks: ' + esc(e.message) + '</p>';
});
}
@@ -100,15 +267,15 @@ function fetchBookmarks() {
function addBookmark() {
var url = encodeURIComponent(document.getElementById('bm-url').value);
var title = encodeURIComponent(document.getElementById('bm-title').value);
var dir = encodeURIComponent(document.getElementById('bm-dir').value);
var path = encodeURIComponent(document.getElementById('bm-path').value);
if (!url) { alert('URL is required'); return; }
location.href = 'sovereign://bookmarks/add?dir=' + dir + '&url=' + url +
location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url +
'&title=' + title;
}
function createDir() {
var name = encodeURIComponent(document.getElementById('new-dir').value);
if (!name) { alert('Directory name is required'); return; }
var name = encodeURIComponent(document.getElementById('new-path').value);
if (!name) { alert('Folder path is required'); return; }
location.href = 'sovereign://bookmarks/createdir?name=' + name;
}
+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);
+369
View File
@@ -0,0 +1,369 @@
/*
* perf-probe.js per-tab performance probe for sovereign_browser
*
* Injected by WebKitUserContentManager into every non-sovereign:// page
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
* - resource entries (PerformanceObserver 'resource') -> net counts
* - requestAnimationFrame cadence -> fps
* - patched setTimeout/setInterval -> active timer count + top intervals
* - patched addEventListener -> event listener count
* - patched Worker -> worker count
* - performance.memory -> heap used
* - document.getElementsByTagName('*').length -> DOM node count
*
* Batches a report every 1s and POSTs it to:
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
*
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
* tag injected before this script runs (see inject_perf_probe() in
* nostr_bridge.c / main.c). If absent, the report is dropped.
*
* The probe is a no-op on sovereign:// pages (the injector skips them)
* to avoid self-noise and recursion.
*/
(function () {
'use strict';
// Honor the skip flag set by the preamble on sovereign:// / about:
// pages. The skip flag is re-evaluated on every navigation (the
// preamble runs at document_start for each new document), so we also
// reset our installed flag here to allow re-injection after a
// navigation from an internal page to a normal one.
if (window.__sbPerfProbeSkip) return;
if (window.__sbPerfProbe) return; // already running in this document
window.__sbPerfProbe = true;
// Tab index is injected by the browser via window.__sbPerfTabIndex
// (set by the preamble that runs before this script). Read it once.
var TAB_INDEX = window.__sbPerfTabIndex;
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
// Capture the native timers early — the heartbeat (below) needs
// origSetTimeout before the timer-patching block runs.
var origSetTimeout = window.setTimeout;
var origSetInterval = window.setInterval;
var origClearTimeout = window.clearTimeout;
var origClearInterval = window.clearInterval;
// ── Long tasks ──────────────────────────────────────────────────
var longTasks = []; // last 30s of {t, duration, name}
var longTasksLastSec = []; // tasks in the current 1s window
var longTaskSources = {}; // name -> {total_ms, count}
var longTaskSupported = false;
if (typeof PerformanceObserver !== 'undefined') {
try {
var ltObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
var rec = {
t: performance.now(),
duration: e.duration,
name: e.name || 'unknown',
attribution: (e.attribution && e.attribution[0])
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
: ''
};
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = rec.name || 'unknown';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += rec.duration;
longTaskSources[key].count++;
});
});
ltObs.observe({ entryTypes: ['longtask'] });
longTaskSupported = true;
} catch (e) { /* longtask not supported */ }
}
// ── Main-thread blockage fallback ───────────────────────────────
// WebKitGTK does not fire 'longtask' entries, so we measure main-
// thread blockage directly: schedule a 0ms heartbeat and measure
// how late it fires. If the main thread is blocked by a busy loop,
// the heartbeat is delayed. The total delay in a 1s window is a
// proxy for cpu_busy_percent.
var heartbeatDelayMs = 0; // accumulated delay in the current window
var heartbeatLast = performance.now();
function heartbeat() {
var now = performance.now();
var expected = heartbeatLast + 50; // we schedule every 50ms
var delay = now - expected;
if (delay > 5) { // ignore small scheduler jitter
heartbeatDelayMs += delay;
// Record as a synthetic long task if > 50ms.
if (delay > 50) {
var rec = { t: now, duration: delay, name: 'main-thread-block',
attribution: '' };
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = 'main-thread-block';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += delay;
longTaskSources[key].count++;
}
}
heartbeatLast = now;
origSetTimeout.call(window, heartbeat, 50);
}
// Always start the heartbeat. On engines where longtask fires
// reliably, the heartbeat delay will be near-zero (long tasks are
// captured by the observer). On WebKitGTK, where longtask is
// accepted but never fires, the heartbeat is the sole source of
// cpu_busy measurement. The report() function takes the max of
// both, so there's no double-counting.
origSetTimeout.call(window, heartbeat, 50);
// ── Resource entries (network) ────────────────────────────────
var netRequestsLastSec = [];
var netEndpoints = {}; // url-prefix -> count
if (typeof PerformanceObserver !== 'undefined') {
try {
var resObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
var key = e.name.split('?')[0];
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
});
});
resObs.observe({ entryTypes: ['resource'] });
} catch (e) { /* resource not supported */ }
}
// ── FPS via requestAnimationFrame ───────────────────────────────
var rafFrames = 0;
var fps = 0;
function rafLoop(ts) {
rafFrames++;
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
var activeTimers = {}; // id -> {code, interval_ms, count}
var timerTop = {}; // "code|interval" -> count
var nextTimerId = 1;
function labelFor(fn) {
try {
var s = (fn && fn.name) ? fn.name : 'anonymous';
// Truncate long inline-function bodies.
if (s.length > 40) s = s.slice(0, 40);
return s;
} catch (e) { return 'anonymous'; }
}
// origSetTimeout / origSetInterval / origClearTimeout /
// origClearInterval were captured at the top of the IIFE (before the
// heartbeat was installed), so they reference the native timers.
window.setTimeout = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
var key = label + '|0';
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
delete activeTimers[id];
return fn.apply(this, args);
};
return origSetTimeout.call(this, wrapped, delay);
};
window.setInterval = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
var interval = delay | 0;
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
var key = label + '|' + interval;
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
if (activeTimers[id]) activeTimers[id].count++;
return fn.apply(this, args);
};
return origSetInterval.call(this, wrapped, delay);
};
window.clearTimeout = function (id) {
delete activeTimers[id];
return origClearTimeout.call(this, id);
};
window.clearInterval = function (id) {
delete activeTimers[id];
return origClearInterval.call(this, id);
};
// ── Event listener count (patched addEventListener) ─────────────
var eventListenerCount = 0;
var origAEL = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
// Skip our own internal listeners (tagged with __sbInternal).
if (!fn || !fn.__sbInternal) eventListenerCount++;
return origAEL.call(this, type, fn, opts);
};
// ── Worker count (patched Worker constructor) ───────────────────
var workerCount = 0;
var OrigWorker = window.Worker;
if (OrigWorker) {
window.Worker = function (url, opts) {
workerCount++;
return new OrigWorker(url, opts);
};
window.Worker.prototype = OrigWorker.prototype;
}
// ── In-flight network (patched fetch + XHR.open) ────────────────
var netInFlight = 0;
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function () {
netInFlight++;
var p = origFetch.apply(this, arguments);
var dec = function () { if (netInFlight > 0) netInFlight--; };
p.then(dec, dec);
return p;
};
}
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
netInFlight++;
var onEnd = function () {
if (netInFlight > 0) netInFlight--;
};
onEnd.__sbInternal = true;
this.addEventListener('loadend', onEnd);
return origXhrOpen.apply(this, arguments);
};
// ── Reporting ───────────────────────────────────────────────────
function report() {
// Trim long-task timeline to last 30s.
var now = performance.now();
while (longTasks.length && (now - longTasks[0].t) > 30000) {
longTasks.shift();
}
// cpu_busy_percent: sum of long-task durations in the last 1s
// window. If the longtask PerformanceObserver isn't supported
// (WebKitGTK), fall back to the heartbeat-delay measurement.
var busyMs = 0;
for (var i = 0; i < longTasksLastSec.length; i++) {
busyMs += longTasksLastSec[i].duration;
}
// If the heartbeat measured more blockage than the longtask
// observer (e.g. on WebKitGTK where longtask never fires), use
// the heartbeat measurement.
if (heartbeatDelayMs > busyMs) {
busyMs = heartbeatDelayMs;
}
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
if (cpuBusy > 100) cpuBusy = 100;
heartbeatDelayMs = 0; // reset for next window
// Top timer sources.
var timerTopArr = [];
Object.keys(timerTop).forEach(function (k) {
var parts = k.split('|');
timerTopArr.push({
code: parts[0],
interval_ms: parseInt(parts[1], 10) || 0,
count: timerTop[k]
});
});
timerTopArr.sort(function (a, b) { return b.count - a.count; });
timerTopArr = timerTopArr.slice(0, 5);
// Top network endpoints (last window).
var netTopArr = [];
Object.keys(netEndpoints).forEach(function (u) {
netTopArr.push({ url: u, count: netEndpoints[u] });
});
netTopArr.sort(function (a, b) { return b.count - a.count; });
netTopArr = netTopArr.slice(0, 5);
// Top long-task sources.
var ltSourcesArr = [];
Object.keys(longTaskSources).forEach(function (k) {
ltSourcesArr.push({
name: k,
total_ms: Math.round(longTaskSources[k].total_ms),
count: longTaskSources[k].count
});
});
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
ltSourcesArr = ltSourcesArr.slice(0, 8);
// Heap.
var heapMb = -1;
if (performance.memory && performance.memory.usedJSHeapSize) {
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
}
// DOM node count.
var domNodes = -1;
try {
domNodes = document.getElementsByTagName('*').length;
} catch (e) { /* document not ready */ }
var payload = {
tab_index: TAB_INDEX,
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
fps: fps,
timer_count: Object.keys(activeTimers).length,
timer_top: timerTopArr,
net_in_flight: netInFlight,
net_requests_last_sec: netRequestsLastSec.length,
net_top_endpoints: netTopArr,
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
event_listener_count: eventListenerCount,
worker_count: workerCount,
dom_node_count: domNodes,
long_tasks_last_sec: longTasksLastSec.map(function (r) {
return {
duration: Math.round(r.duration),
name: r.name,
attribution: r.attribution
};
}),
long_task_top_sources: ltSourcesArr,
long_task_timeline: longTasks.map(function (r) {
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
})
};
// Reset per-window accumulators.
longTasksLastSec = [];
netRequestsLastSec = [];
netEndpoints = {};
timerTop = {};
// Send. Use sync XHR to sovereign:// (same convention as the
// window.nostr shim — WebKit's custom scheme handler doesn't
// reliably support fetch() or async XHR for sovereign:// URLs).
try {
var body = encodeURIComponent(JSON.stringify(payload));
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
'&body=' + body;
var x = new XMLHttpRequest();
x.open('GET', url, false); // synchronous
x.send();
} catch (e) { /* swallow — probe must never break the page */ }
}
// FPS counter: count frames in each 1s window.
setInterval(function () {
fps = rafFrames;
rafFrames = 0;
}, 1000);
// Report every 1s. Use the original setInterval so we don't track
// ourselves in activeTimers.
origSetInterval.call(window, report, 1000);
})();
+144
View File
@@ -0,0 +1,144 @@
/* Processes page sovereign://processes
* Imports the shared sovereign:// base theme, then adds processes-specific
* layout for the process/tab tables, heat bars, and drill-down panel. */
@import url("sovereign://sovereign-base.css");
.note { font-size: 12px; font-weight: normal; }
code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; }
.url-sub { font-size: 11px; }
.dim { color: var(--muted); }
/* Tab navigation (shared with fips.css pattern). */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab-btn {
background: var(--bg); color: var(--muted);
border: 1px solid transparent; border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
padding: 6px 16px; cursor: pointer;
font-family: var(--font); font-size: 13px; font-weight: bold;
margin-left: 0; min-width: auto;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active {
color: var(--primary);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
#refresh-btn { margin-left: 12px; }
/* Process + tab tables. */
#proc-table, #tabs-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#proc-table th, #proc-table td,
#tabs-table th, #tabs-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
white-space: nowrap;
}
#proc-table th, #tabs-table th {
color: var(--muted);
font-weight: bold;
border-bottom: 2px solid var(--primary);
cursor: pointer;
user-select: none;
}
#proc-table th:hover, #tabs-table th:hover { color: var(--primary); }
#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); }
#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); }
#proc-table td.title-cell,
#tabs-table td.title-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
#proc-table td.url-cell,
#tabs-table td.url-cell {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
}
/* CPU values: plain text, no shaded background. Red only for >= 90%,
* otherwise the default foreground color. */
.heat {
font-weight: bold;
color: var(--fg);
}
.heat.hot { color: var(--accent); }
/* Renderer row: clickable to expand hosted tabs. */
.renderer-row { cursor: pointer; }
.renderer-row:hover { background: var(--border); }
.hosted-tabs-row td { padding-left: 24px; color: var(--muted); }
.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; }
.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; }
/* Drill-down panel. */
#drilldown {
margin-top: 24px;
border-top: 2px solid var(--primary);
padding-top: 12px;
}
#drilldown h2 { margin-bottom: 8px; }
#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; }
.sub-table {
width: 100%;
border-collapse: collapse;
margin: 6px 0 12px;
}
.sub-table th, .sub-table td {
text-align: left;
padding: 4px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.sub-table th { color: var(--muted); font-weight: bold; }
.sub-table td.mono { font-family: var(--font); word-break: break-all; }
/* Long-task timeline: a horizontal bar of vertical lines, one per
* long task, height scaled by duration. */
.timeline {
height: 80px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
overflow: hidden;
position: relative;
background: var(--bg);
}
.timeline .tl-bar {
display: inline-block;
width: 2px;
margin-right: 1px;
background: var(--accent);
vertical-align: bottom;
}
.timeline .tl-empty {
color: var(--muted);
font-size: 12px;
padding: 4px 8px;
}
.actions { margin-top: 16px; }
.actions .btn { margin-right: 8px; }
.empty { color: var(--muted); font-style: italic; }
.mono { font-family: var(--font); word-break: break-all; }
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Processes — Sovereign Browser</title>
<link rel="stylesheet" href="sovereign://processes.css">
</head>
<body>
<h1>Processes <button class="btn" id="refresh-btn">Refresh</button></h1>
<div class="tabs">
<button class="tab-btn active" data-tab="processes">Processes</button>
<button class="tab-btn" data-tab="tabs">Tabs</button>
</div>
<div id="status-msg" class="status"></div>
<!-- Processes tab -->
<div class="tab-panel active" id="tab-processes">
<p class="note">
All sovereign_browser processes: the main browser, WebKit renderer
/ network / GPU processes, and managed Tor/FIPS subprocesses.
CPU% is computed from <code>/proc</code> utime+stime deltas
(first sample is always 0). Renderer rows expand to show their
hosted tabs.
</p>
<table id="proc-table">
<thead>
<tr>
<th data-sort="pid">PID</th>
<th data-sort="name">Name</th>
<th data-sort="cpu_percent">CPU%</th>
<th data-sort="rss_kb">RSS</th>
<th data-sort="pss_kb">PSS</th>
<th data-sort="threads">Threads</th>
<th data-sort="uptime_sec">Uptime</th>
<th data-sort="state">State</th>
<th data-sort="ownership">Type</th>
<th>Tabs</th>
</tr>
</thead>
<tbody id="proc-body"></tbody>
</table>
</div>
<!-- Tabs tab -->
<div class="tab-panel" id="tab-tabs">
<p class="note">
Per-tab performance from the in-page probe. <b>CPU-busy%</b> is
the fraction of the last second spent in long (>50ms)
main-thread tasks — the direct measure of which tab is hogging
CPU. Click a row for a drill-down (long-task sources, timers,
network endpoints, heap, workers).
</p>
<table id="tabs-table">
<thead>
<tr>
<th data-sort="index">#</th>
<th data-sort="title">Title</th>
<th data-sort="cpu_busy_percent">CPU-busy%</th>
<th data-sort="fps">FPS</th>
<th data-sort="timer_count">Timers</th>
<th data-sort="net_in_flight">Net</th>
<th data-sort="heap_used_mb">Heap</th>
<th data-sort="dom_node_count">DOM</th>
<th data-sort="webprocess_pid">WPID</th>
</tr>
</thead>
<tbody id="tabs-body"></tbody>
</table>
<!-- Drill-down panel -->
<div id="drilldown" style="display:none">
<h2 id="dd-title">Tab detail</h2>
<div class="card" id="dd-summary"></div>
<h3>Long-task sources <span class="note">(top scripts by total ms)</span></h3>
<table class="sub-table" id="dd-lt-sources">
<thead><tr><th>name</th><th>total ms</th><th>count</th></tr></thead>
<tbody id="dd-lt-sources-body"></tbody>
</table>
<h3>Active timers <span class="note">(top by frequency)</span></h3>
<table class="sub-table" id="dd-timers">
<thead><tr><th>code</th><th>interval ms</th><th>count</th></tr></thead>
<tbody id="dd-timers-body"></tbody>
</table>
<h3>Network endpoints <span class="note">(top by request count)</span></h3>
<table class="sub-table" id="dd-net">
<thead><tr><th>url</th><th>count</th></tr></thead>
<tbody id="dd-net-body"></tbody>
</table>
<h3>Long-task timeline <span class="note">(last 30s)</span></h3>
<div id="dd-timeline" class="timeline"></div>
<div class="actions">
<button class="btn" id="dd-reload">Reload tab</button>
<button class="btn" id="dd-suspend">Suspend tab</button>
<button class="btn" id="dd-close">Close tab</button>
<button class="btn" id="dd-close-panel">Close panel</button>
</div>
</div>
</div>
<script src="sovereign://processes.js"></script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
/* Processes page — sovereign://processes
*
* Tabs: Processes | Tabs
*
* - Processes: /proc-level view of all sovereign_browser PIDs (main,
* WebKit renderers with their hosted tabs, WebKit network/gpu, Tor,
* FIPS). Polled from sovereign://processes/list every 1s. Sortable
* columns. Renderer rows expand to show hosted tabs.
* - Tabs: per-tab probe view (cpu_busy_percent, fps, timers, net, heap,
* DOM, WPID). Polled from sovereign://processes/tabs every 1s.
* Click a row to load the drill-down (sovereign://processes/tab_probe).
*
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
* handler does not reliably support fetch() for sovereign:// URLs.
*/
function sovereignGet(url) {
return new Promise(function (resolve, reject) {
var x = new XMLHttpRequest();
x.open('GET', url, true);
x.onreadystatechange = function () {
if (x.readyState !== 4) return;
try { resolve(JSON.parse(x.responseText)); }
catch (e) { reject(e); }
};
x.onerror = function () { reject(new Error('XHR error')); };
x.send();
});
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showMsg(text, isErr) {
var el = document.getElementById('status-msg');
el.textContent = text || '';
el.className = 'status show ' + (isErr ? 'err' : 'ok');
if (!text) el.className = 'status';
}
/* ── Formatting helpers ─────────────────────────────────────────── */
function fmtKb(kb) {
if (kb == null || kb < 0) return '—';
if (kb >= 1024 * 1024) return (kb / (1024 * 1024)).toFixed(1) + 'G';
if (kb >= 1024) return (kb / 1024).toFixed(0) + 'M';
return kb + 'K';
}
function fmtUptime(sec) {
if (sec == null || sec < 0) return '—';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = Math.floor(sec % 60);
if (d > 0) return d + 'd' + h + 'h';
if (h > 0) return h + 'h' + m + 'm';
if (m > 0) return m + 'm' + s + 's';
return s + 's';
}
function heatClass(pct) {
if (pct == null) return '';
if (pct >= 90) return 'hot';
return '';
}
function heatHtml(pct) {
if (pct == null) return '<span class="heat">—</span>';
return '<span class="heat ' + heatClass(pct) + '">' +
pct.toFixed(1) + '</span>';
}
/* ── Tab switching ──────────────────────────────────────────────── */
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(function (panel) {
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
});
}
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () { switchTab(btn.dataset.tab); });
});
/* ── Processes tab ──────────────────────────────────────────────── */
var gProcSortKey = 'cpu_percent';
var gProcSortDir = 'desc';
var gProcData = [];
var gExpandedRenderers = {}; // pid -> bool
function setProcSort(key) {
if (gProcSortKey === key) {
gProcSortDir = gProcSortDir === 'asc' ? 'desc' : 'asc';
} else {
gProcSortKey = key;
gProcSortDir = (key === 'name' || key === 'state' || key === 'ownership')
? 'asc' : 'desc';
}
renderProcTable();
}
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setProcSort(th.dataset.sort); });
});
function procSorter(a, b) {
var ka = a[gProcSortKey];
var kb = b[gProcSortKey];
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gProcSortDir === 'asc' ? cmp : -cmp;
}
function renderProcTable() {
var body = document.getElementById('proc-body');
body.innerHTML = '';
var sorted = gProcData.slice().sort(procSorter);
// Update sort indicators.
document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gProcSortKey) {
th.classList.add(gProcSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (p) {
var tr = document.createElement('tr');
var isRenderer = p.ownership === 'webkit-renderer';
tr.className = isRenderer ? 'renderer-row' : '';
var tabsCount = (p.hosted_tabs || []).length;
var tabsCell;
if (isRenderer) {
tabsCell = String(tabsCount);
} else {
tabsCell = '—';
}
tr.innerHTML =
'<td>' + p.pid + '</td>' +
'<td class="title-cell">' + esc(p.name) + '</td>' +
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
'<td>' + fmtKb(p.rss_kb) + '</td>' +
'<td>' + fmtKb(p.pss_kb) + '</td>' +
'<td>' + (p.threads || 0) + '</td>' +
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
'<td>' + esc(p.state) + '</td>' +
'<td>' + esc(p.ownership) +
(p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '</td>' +
'<td>' + tabsCell + '</td>';
if (isRenderer) {
(function (pid, hosted) {
tr.addEventListener('click', function () {
gExpandedRenderers[pid] = !gExpandedRenderers[pid];
renderProcTable();
});
})(p.pid, p.hosted_tabs);
}
body.appendChild(tr);
// Expanded hosted-tabs row.
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
var sub = document.createElement('tr');
sub.className = 'hosted-tabs-row';
var html = '<td colspan="10">';
p.hosted_tabs.forEach(function (t) {
html += '<span class="ht-entry"><span class="ht-idx">#' +
t.index + '</span>' + esc(t.title || '(untitled)') +
' <span class="url-sub">' + esc(t.url) + '</span></span>';
});
html += '</td>';
sub.innerHTML = html;
body.appendChild(sub);
}
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
}
}
async function fetchProcesses() {
try {
var data = await sovereignGet('sovereign://processes/list?_t=' + Date.now());
gProcData = Array.isArray(data) ? data : [];
renderProcTable();
} catch (e) {
showMsg('Failed to load processes: ' + e.message, true);
}
}
/* ── Tabs tab ───────────────────────────────────────────────────── */
var gTabsSortKey = 'cpu_busy_percent';
var gTabsSortDir = 'desc';
var gTabsData = [];
var gSelectedTab = -1;
function setTabsSort(key) {
if (gTabsSortKey === key) {
gTabsSortDir = gTabsSortDir === 'asc' ? 'desc' : 'asc';
} else {
gTabsSortKey = key;
gTabsSortDir = (key === 'title') ? 'asc' : 'desc';
}
renderTabsTable();
}
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () { setTabsSort(th.dataset.sort); });
});
function tabSorter(a, b) {
var ka = a[gTabsSortKey];
var kb = b[gTabsSortKey];
// For probe fields, fall back to -1 if probe is null.
if (gTabsSortKey !== 'index' && gTabsSortKey !== 'title' &&
gTabsSortKey !== 'webprocess_pid') {
ka = (a.probe && a.probe[gTabsSortKey] != null) ? a.probe[gTabsSortKey] : -1;
kb = (b.probe && b.probe[gTabsSortKey] != null) ? b.probe[gTabsSortKey] : -1;
}
var cmp;
if (typeof ka === 'number' || typeof kb === 'number') {
cmp = (ka || 0) - (kb || 0);
} else {
cmp = String(ka || '').localeCompare(String(kb || ''));
}
return gTabsSortDir === 'asc' ? cmp : -cmp;
}
function probeField(tab, key, fmt) {
if (!tab.probe) return '—';
var v = tab.probe[key];
if (v == null || v < 0) return '—';
return fmt ? fmt(v) : v;
}
function renderTabsTable() {
var body = document.getElementById('tabs-body');
body.innerHTML = '';
var sorted = gTabsData.slice().sort(tabSorter);
document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
if (th.dataset.sort === gTabsSortKey) {
th.classList.add(gTabsSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
});
sorted.forEach(function (t) {
var tr = document.createElement('tr');
tr.className = 'tab-row' + (t.index === gSelectedTab ? ' selected' : '');
var busy = (t.probe && t.probe.cpu_busy_percent != null)
? t.probe.cpu_busy_percent : null;
var titleClass = (t.index === gSelectedTab) ? '' : 'dim';
tr.innerHTML =
'<td>' + t.index + '</td>' +
'<td class="title-cell"><span class="' + titleClass + '">' +
esc(t.title || '(untitled)') + '</span>' +
(t.is_internal ? ' (internal)' : '') +
'<br><span class="url-sub">' + esc(t.url) + '</span></td>' +
'<td>' + (t.is_internal ? '<span class="heat">—</span>'
: heatHtml(busy)) + '</td>' +
'<td>' + probeField(t, 'fps') + '</td>' +
'<td>' + probeField(t, 'timer_count') + '</td>' +
'<td>' + probeField(t, 'net_in_flight') + '</td>' +
'<td>' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '</td>' +
'<td>' + probeField(t, 'dom_node_count') + '</td>' +
'<td>' + (t.webprocess_pid || 0) + '</td>';
(function (idx) {
tr.addEventListener('click', function () { openDrilldown(idx); });
})(t.index);
body.appendChild(tr);
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="9" class="empty">No tabs.</td></tr>';
}
}
async function fetchTabs() {
try {
var data = await sovereignGet('sovereign://processes/tabs?_t=' + Date.now());
gTabsData = Array.isArray(data) ? data : [];
renderTabsTable();
// If a tab is selected, refresh its drill-down too.
if (gSelectedTab >= 0) refreshDrilldown();
} catch (e) {
showMsg('Failed to load tabs: ' + e.message, true);
}
}
/* ── Drill-down ─────────────────────────────────────────────────── */
async function openDrilldown(idx) {
gSelectedTab = idx;
renderTabsTable();
document.getElementById('drilldown').style.display = 'block';
await refreshDrilldown();
}
async function refreshDrilldown() {
if (gSelectedTab < 0) return;
try {
var d = await sovereignGet('sovereign://processes/tab_probe?index=' +
gSelectedTab);
renderDrilldown(d);
} catch (e) {
document.getElementById('dd-title').textContent = 'Tab #' + gSelectedTab;
document.getElementById('dd-summary').innerHTML =
'<p class="err-text">Failed to load drill-down: ' + esc(e.message) + '</p>';
}
}
function renderDrilldown(d) {
document.getElementById('dd-title').textContent =
'Tab #' + d.index + ' — ' + (d.title || '(untitled)');
var p = d.probe || {};
var rows = [
['URL', esc(d.url)],
['WebProcess PID', d.webprocess_pid || 0],
['CPU-busy %', p.cpu_busy_percent != null ? heatHtml(p.cpu_busy_percent) : '—'],
['FPS', p.fps != null ? p.fps : '—'],
['Active timers', p.timer_count != null ? p.timer_count : '—'],
['Net in-flight', p.net_in_flight != null ? p.net_in_flight : '—'],
['Net reqs (last sec)', p.net_requests_last_sec != null ? p.net_requests_last_sec : '—'],
['Heap used', p.heap_used_mb >= 0 ? p.heap_used_mb + 'M' : '—'],
['Event listeners', p.event_listener_count != null ? p.event_listener_count : '—'],
['Workers', p.worker_count != null ? p.worker_count : '—'],
['DOM nodes', p.dom_node_count >= 0 ? p.dom_node_count : '—']
];
var html = '';
rows.forEach(function (r) {
html += '<div class="info-row"><span>' + r[0] + '</span><span>' +
r[1] + '</span></div>';
});
document.getElementById('dd-summary').innerHTML = html;
// Long-task sources.
var ltBody = document.getElementById('dd-lt-sources-body');
ltBody.innerHTML = '';
(p.long_task_top_sources || []).forEach(function (s) {
ltBody.innerHTML += '<tr><td class="mono">' + esc(s.name) + '</td><td>' +
s.total_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) {
ltBody.innerHTML = '<tr><td colspan="3" class="empty">No long tasks.</td></tr>';
}
// Timers.
var tmBody = document.getElementById('dd-timers-body');
tmBody.innerHTML = '';
(p.timer_top || []).forEach(function (s) {
tmBody.innerHTML += '<tr><td class="mono">' + esc(s.code) + '</td><td>' +
s.interval_ms + '</td><td>' + s.count + '</td></tr>';
});
if (!p.timer_top || p.timer_top.length === 0) {
tmBody.innerHTML = '<tr><td colspan="3" class="empty">No active timers.</td></tr>';
}
// Network endpoints.
var netBody = document.getElementById('dd-net-body');
netBody.innerHTML = '';
(p.net_top_endpoints || []).forEach(function (s) {
netBody.innerHTML += '<tr><td class="mono">' + esc(s.url) + '</td><td>' +
s.count + '</td></tr>';
});
if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) {
netBody.innerHTML = '<tr><td colspan="2" class="empty">No network requests.</td></tr>';
}
// Timeline.
var tl = document.getElementById('dd-timeline');
tl.innerHTML = '';
var timeline = p.long_task_timeline || [];
if (timeline.length === 0) {
tl.innerHTML = '<div class="tl-empty">No long tasks in the last 30s.</div>';
} else {
var maxDur = 1;
timeline.forEach(function (r) { if (r.duration > maxDur) maxDur = r.duration; });
timeline.forEach(function (r) {
var h = Math.max(2, Math.round((r.duration / maxDur) * 72));
var bar = document.createElement('span');
bar.className = 'tl-bar';
bar.style.height = h + 'px';
bar.title = r.duration + 'ms — ' + (r.name || 'unknown');
tl.appendChild(bar);
});
}
}
/* ── Drill-down actions ─────────────────────────────────────────── */
document.getElementById('dd-reload').addEventListener('click', function () {
if (gSelectedTab < 0) return;
// Trigger reload via the bridge — there's no dedicated endpoint, so
// we use the agent eval path indirectly. Simplest: navigate the tab
// by opening its URL again via sovereign://processes/tab_action.
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=reload').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-suspend').addEventListener('click', function () {
if (gSelectedTab < 0) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=suspend').then(function () { refreshDrilldown(); });
});
document.getElementById('dd-close').addEventListener('click', function () {
if (gSelectedTab < 0) return;
if (!confirm('Close tab #' + gSelectedTab + '?')) return;
sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab +
'&action=close').then(function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
fetchTabs();
});
});
document.getElementById('dd-close-panel').addEventListener('click', function () {
document.getElementById('drilldown').style.display = 'none';
gSelectedTab = -1;
renderTabsTable();
});
/* ── Refresh button + polling ───────────────────────────────────── */
document.getElementById('refresh-btn').addEventListener('click', function () {
fetchProcesses();
fetchTabs();
});
function poll() {
fetchProcesses();
fetchTabs();
}
poll();
setInterval(poll, 1000);
+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">