diff --git a/Makefile b/Makefile index 356feea..bd60d06 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/VERSION b/VERSION index 1df5b7e..fad9d0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.46 +0.0.47 diff --git a/plans/processes-window.md b/plans/processes-window.md new file mode 100644 index 0000000..2d0eace --- /dev/null +++ b/plans/processes-window.md @@ -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` 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//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//comm` | +| cmdline | `/proc//cmdline` (NUL-split, space-joined) | +| state | `/proc//stat` field 3 (R/S/D/Z/T) | +| ppid | `/proc//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//status` → `VmRSS` | +| pss_kb | `/proc//smaps_rollup` → `Pss` (fallback to RSS if unavailable) | +| uss_kb | `/proc//smaps_rollup` → `Private_Clean` + `Private_Dirty` | +| vmpeak_kb | `/proc//status` → `VmPeak` | +| vmswap_kb | `/proc//status` → `VmSwap` | +| threads | count of entries in `/proc//task/` | +| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` | +| io_read_kb | `/proc//io` → `read_bytes` / 1024 | +| io_write_kb | `/proc//io` → `write_bytes` / 1024 | +| fd_count | count of entries in `/proc//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` 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 `` 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). diff --git a/src/agent_mcp.c b/src/agent_mcp.c index 93b47e7..d49a15d 100644 --- a/src/agent_mcp.c +++ b/src/agent_mcp.c @@ -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])); diff --git a/src/agent_tools.c b/src/agent_tools.c index f37890b..74cd1e3 100644 --- a/src/agent_tools.c +++ b/src/agent_tools.c @@ -16,6 +16,7 @@ #include "settings.h" #include "search.h" #include "nostr_url.h" +#include "process_info.h" #include #include @@ -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 || diff --git a/src/main.c b/src/main.c index 7272183..96a80ca 100644 --- a/src/main.c +++ b/src/main.c @@ -281,60 +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_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"); - } + tab_manager_new_tab("sovereign://agents"); } -/* The hamburger-menu "FIPS Mesh…" item navigates the active tab to - * the sovereign://fips internal page, which renders the FIPS mesh - * network status + management UI. */ +/* 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"); +} - tab_info_t *tab = tab_manager_get_active(); - if (tab && tab->webview) { - webkit_web_view_load_uri(tab->webview, "sovereign://fips"); - } else { - tab_manager_new_tab("sovereign://fips"); - } +/* 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 --------------------------------------------- * @@ -462,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; diff --git a/src/nostr_bridge.c b/src/nostr_bridge.c index 0b1cb4d..e1b2a6f 100644 --- a/src/nostr_bridge.c +++ b/src/nostr_bridge.c @@ -34,6 +34,7 @@ #include "net_services.h" #include "fips_control.h" #include "relay_fetch.h" +#include "process_info.h" /* ── Global bridge state ────────────────────────────────────────── * * The URI scheme callback needs access to the current signer. We keep @@ -1091,6 +1092,7 @@ static void handle_settings_set(WebKitURISchemeRequest *request, new_state = bs->theme_dark; settings_save(); settings_sync_publish(); + tab_manager_apply_settings(); /* re-apply GTK app theme */ reload = 0; /* Don't reload — the JS handles the visual toggle */ } else if (strcmp(feature, "agent_server_enabled") == 0) { bs->agent_server_enabled = !bs->agent_server_enabled; @@ -2976,6 +2978,138 @@ static void handle_settings_config_json(WebKitURISchemeRequest *request) { free(json); } +/* ── Processes / performance page (sovereign://processes) ─────────── */ + +/* Handle sovereign://processes/list — return the Layer 1 process list + * as a JSON array. Polled by www/processes.js every 1s. */ +static void handle_processes_list_json(WebKitURISchemeRequest *request) { + cJSON *arr = process_info_get_processes_json(); + char *json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://processes/tabs — return the Layer 2 tab list + * (tab identity + latest probe) as a JSON array. */ +static void handle_processes_tabs_json(WebKitURISchemeRequest *request) { + cJSON *arr = process_info_get_tabs_json(); + char *json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://processes/tab_probe?index=N — return the drill-down + * JSON for a single tab (full probe with timeline + top sources). */ +static void handle_processes_tab_probe_json(WebKitURISchemeRequest *request, + const char *query) { + int idx = -1; + if (query) { + const char *p = strstr(query, "index="); + if (p) idx = atoi(p + 6); + } + if (idx < 0) { + respond_error_json(request, -1, "Missing or invalid index"); + return; + } + cJSON *obj = process_info_get_tab_probe_json(idx); + if (obj == NULL) { + respond_error_json(request, -1, "Tab index out of range"); + return; + } + char *json = cJSON_PrintUnformatted(obj); + cJSON_Delete(obj); + respond_json(request, json); + free(json); +} + +/* Handle sovereign://processes/probe-report?tab_index=N&body= + * Records the probe report from a page's perf-probe.js. The body is + * URL-encoded JSON passed as a query parameter (same convention as the + * window.nostr shim — WebKitGTK's custom scheme handler doesn't expose + * POST bodies cleanly). Returns 204 No Content (empty JSON). */ +static void handle_processes_probe_report(WebKitURISchemeRequest *request, + const char *query) { + int tab_index = -1; + const char *body_encoded = NULL; + if (query) { + /* Parse "tab_index=N&body=" or "body=&tab_index=N". */ + const char *p = strstr(query, "tab_index="); + if (p) tab_index = atoi(p + 10); + p = strstr(query, "body="); + if (p) body_encoded = p + 5; + } + if (tab_index < 0 || body_encoded == NULL) { + respond_json(request, "{\"status\":\"ignored\"}"); + return; + } + /* URL-decode the body. */ + char *body = g_uri_unescape_string(body_encoded, NULL); + if (body == NULL) { + respond_json(request, "{\"status\":\"decode_failed\"}"); + return; + } + cJSON *probe = cJSON_Parse(body); + g_free(body); + if (probe == NULL) { + respond_json(request, "{\"status\":\"bad_json\"}"); + return; + } + process_info_record_probe(tab_index, probe); /* takes ownership */ + respond_json(request, "{\"status\":\"ok\"}"); +} + +/* Handle sovereign://processes/tab_action?index=N&action=reload|suspend|close + * Performs a tab lifecycle action from the drill-down panel. */ +static void handle_processes_tab_action(WebKitURISchemeRequest *request, + const char *query) { + int idx = -1; + char action[16] = ""; + if (query) { + const char *p = strstr(query, "index="); + if (p) idx = atoi(p + 6); + p = strstr(query, "action="); + if (p) { + const char *v = p + 7; + size_t i = 0; + while (v[i] && v[i] != '&' && i < sizeof(action) - 1) { + action[i] = v[i]; i++; + } + action[i] = '\0'; + } + } + if (idx < 0 || idx >= tab_manager_count()) { + respond_error_json(request, -1, "Invalid tab index"); + return; + } + if (strcmp(action, "reload") == 0) { + tab_info_t *tab = tab_manager_get(idx); + if (tab && tab->webview) webkit_web_view_reload(tab->webview); + respond_json(request, "{\"status\":\"reloaded\"}"); + } else if (strcmp(action, "suspend") == 0) { + /* Suspend = navigate to about:blank, preserving the URL in + * current_url so the user can see what was suspended. The + * tab_manager load handler will overwrite current_url, so we + * restore it after the load. */ + tab_info_t *tab = tab_manager_get(idx); + if (tab && tab->webview) { + char saved[TAB_URL_MAX]; + strncpy(saved, tab->current_url, sizeof(saved) - 1); + saved[sizeof(saved) - 1] = '\0'; + webkit_web_view_load_uri(tab->webview, "about:blank"); + strncpy(tab->current_url, saved, sizeof(tab->current_url) - 1); + tab->current_url[sizeof(tab->current_url) - 1] = '\0'; + } + respond_json(request, "{\"status\":\"suspended\"}"); + } else if (strcmp(action, "close") == 0) { + tab_manager_close_tab(idx); + respond_json(request, "{\"status\":\"closed\"}"); + } else { + respond_error_json(request, -1, "Unknown action"); + } +} + /* ── FIPS mesh management page (sovereign://fips) ─────────────────── */ /* Map a service_state_t to a lowercase string for the JSON status. */ @@ -4222,6 +4356,46 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, return; } + /* Route sovereign://processes — process + per-tab performance page. + * Served from embedded www/processes.{html,css,js}. */ + if (strcmp(uri, "sovereign://processes") == 0 || + strncmp(uri, "sovereign://processes?", 22) == 0) { + serve_embedded_file(request, "processes.html"); + return; + } + if (strcmp(uri, "sovereign://processes.css") == 0 || + strncmp(uri, "sovereign://processes.css?", 26) == 0) { + serve_embedded_file(request, "processes.css"); + return; + } + if (strcmp(uri, "sovereign://processes.js") == 0 || + strncmp(uri, "sovereign://processes.js?", 25) == 0) { + serve_embedded_file(request, "processes.js"); + return; + } + if (strcmp(uri, "sovereign://processes/list") == 0 || + strncmp(uri, "sovereign://processes/list?", 26) == 0) { + handle_processes_list_json(request); + return; + } + if (strcmp(uri, "sovereign://processes/tabs") == 0 || + strncmp(uri, "sovereign://processes/tabs?", 26) == 0) { + handle_processes_tabs_json(request); + return; + } + if (strncmp(uri, "sovereign://processes/tab_probe?", 32) == 0) { + handle_processes_tab_probe_json(request, uri + 32); + return; + } + if (strncmp(uri, "sovereign://processes/probe-report?", 35) == 0) { + handle_processes_probe_report(request, uri + 35); + return; + } + if (strncmp(uri, "sovereign://processes/tab_action?", 33) == 0) { + handle_processes_tab_action(request, uri + 33); + return; + } + /* Route sovereign://fips — FIPS mesh management page. */ if (strcmp(uri, "sovereign://fips") == 0 || strncmp(uri, "sovereign://fips?", 17) == 0) { diff --git a/src/perf_probe.c b/src/perf_probe.c new file mode 100644 index 0000000..1328bd1 --- /dev/null +++ b/src/perf_probe.c @@ -0,0 +1,102 @@ +/* + * 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 + * 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 +#include + +/* 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 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); + + g_print("[perf-probe] perf probe installed for tab %d\n", tab_index); +} diff --git a/src/perf_probe.h b/src/perf_probe.h new file mode 100644 index 0000000..9c7a039 --- /dev/null +++ b/src/perf_probe.h @@ -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 + +#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 */ diff --git a/src/process_info.c b/src/process_info.c new file mode 100644 index 0000000..3db463d --- /dev/null +++ b/src/process_info.c @@ -0,0 +1,560 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include + +#include + +/* ── 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//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//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//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//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//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; + /* Cap at 100 * cpu_count to avoid absurd values; we report + * per-process so >100 is valid on multicore. */ + } + } + 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; +} diff --git a/src/process_info.h b/src/process_info.h new file mode 100644 index 0000000..2a64ece --- /dev/null +++ b/src/process_info.h @@ -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 */ diff --git a/src/shortcuts.c b/src/shortcuts.c index ac79865..0db96a1 100644 --- a/src/shortcuts.c +++ b/src/shortcuts.c @@ -78,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = { "open_settings", "Open settings", "Open the sovereign://settings page", "comma" }, + [SHORTCUT_OPEN_PROCESSES] = { + "open_processes", "Open processes", + "Open the sovereign://processes diagnostics page", + "Escape" + }, [SHORTCUT_NEW_IDENTITY] = { "new_identity", "Switch identity", "Open the Nostr login dialog to switch identity", diff --git a/src/shortcuts.h b/src/shortcuts.h index 8ee40ea..6299b97 100644 --- a/src/shortcuts.h +++ b/src/shortcuts.h @@ -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, diff --git a/src/tab_manager.c b/src/tab_manager.c index feb9fb7..8294af3 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -10,6 +10,7 @@ #include "tab_manager.h" #include "settings.h" #include "nostr_inject.h" +#include "perf_probe.h" #include "nostr_url.h" #include "history.h" #include "bookmarks.h" @@ -68,6 +69,7 @@ extern void on_menu_settings(GtkMenuItem *item, gpointer data); extern void on_menu_profile(GtkMenuItem *item, gpointer data); extern void on_menu_agent(GtkMenuItem *item, gpointer data); extern void on_menu_fips(GtkMenuItem *item, gpointer data); +extern void on_menu_processes(GtkMenuItem *item, gpointer data); /* Key press handler defined in main.c — connected to each webview so * keyboard shortcuts are caught before WebKit consumes the event. */ @@ -1256,6 +1258,10 @@ static GtkWidget *tab_manager_new_window(const char *url, return NULL; } + /* Inject the per-tab performance probe (sovereign://processes). + * Skips internal sovereign:// pages at runtime via the preamble. */ + perf_probe_setup(tab->webview, index); + const browser_settings_t *s = settings_get(); int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook), tab->page, tab->tab_label); @@ -2381,6 +2387,11 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { G_CALLBACK(on_menu_fips), g_window); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips_page); + GtkWidget *item_processes = gtk_menu_item_new_with_label("Processes…"); + g_signal_connect(item_processes, "activate", + G_CALLBACK(on_menu_processes), g_window); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_processes); + GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…"); g_signal_connect(item_settings, "activate", G_CALLBACK(on_menu_settings), g_window); @@ -2831,6 +2842,7 @@ static tab_info_t *tab_create(const char *url) { * GtkMenuButton popovers for subfolders. Refreshed whenever bookmarks * change (see bookmark_bar_refresh_all via bookmarks_subscribe_changed). */ tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_name(tab->bookmark_bar, "bookmark-bar"); 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); @@ -3149,6 +3161,71 @@ static void on_new_tab_clicked(GtkButton *btn, gpointer data) { * image is created and tracked in g_avatar_images so it * still gets updated when the picture arrives. */ + +/* ── App theme (GTK CSS) ───────────────────────────────────────────── * + * Applies the sovereign_browser visual theme to the GTK native UI: + * - Red accent (#ff0000) for URL entry focus, tab active/highlight + * - Button scheme matching the client project: black border, white bg, + * red hover border, red active bg (swapped for dark mode) + * Called at init and when the theme_dark setting changes. */ +static GtkCssProvider *g_app_theme_provider = NULL; + +static void apply_app_theme(void) { + const browser_settings_t *s = settings_get(); + int dark = s ? s->theme_dark : 0; + + /* Colors: light mode = black-on-white, dark mode = white-on-black. + * Only the tab underline and URL focus ring are overridden — buttons + * and everything else stay with the OS GTK theme to avoid breaking + * the theme's rendering logic. */ + const char *fg = dark ? "#ffffff" : "#000000"; + + char *css = g_strdup_printf( + /* Avatar / hamburger button sizing */ + "#avatar-btn, #hamburger-btn {" + " padding: 0px;" + " min-width: 28px; min-height: 28px;" + " border-radius: 4px;" + "}" + "#avatar-btn image, #hamburger-btn image {" + " padding: 0px; margin: 0px;" + "}" + /* ── Red accent for the URL entry focus ring ──────────────── */ + "entry:focus {" + " border-color: #ff0000 !important;" + " box-shadow: 0 0 0 1px #ff0000 !important;" + "}" + /* ── Tab active underline: black bar (replaces blue) ──────── + * Adwaita renders the active-tab highlight as a box-shadow inset + * on tab:checked. We kill that and draw a solid border-bottom + * in the fg color. !important is needed to beat the theme. */ + "notebook tab:checked {" + " box-shadow: none !important;" + " outline: none !important;" + " border-bottom: 3px solid %s !important;" + "}" + /* Tab text: normal (inherit from theme), no red. */ + /* ── Bookmark bar buttons — compact padding ───────────────── */ + "#bookmark-bar button {" + " padding: 2px 8px;" + "}", + fg /* tab:checked border-bottom color */ + ); + + if (g_app_theme_provider == NULL) { + g_app_theme_provider = gtk_css_provider_new(); + /* USER priority (highest) so the tab/entry overrides beat the + * GTK theme. We only override specific properties (tab underline, + * entry focus), so the rest of the theme is untouched. */ + gtk_style_context_add_provider_for_screen( + gdk_screen_get_default(), + GTK_STYLE_PROVIDER(g_app_theme_provider), + GTK_STYLE_PROVIDER_PRIORITY_USER); + } + gtk_css_provider_load_from_data(g_app_theme_provider, css, -1, NULL); + g_free(css); +} + static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main) { g_return_if_fail(notebook != NULL && GTK_IS_NOTEBOOK(notebook)); @@ -3228,23 +3305,14 @@ void tab_manager_init(GtkContainer *parent, * fill edge-to-edge. Force both to a fixed square size (28x28) and * restore rounded corners so the avatar image is clipped to the * button's rounded shape. Sizes are also set via - * gtk_widget_set_size_request() in C. */ - GtkCssProvider *css = gtk_css_provider_new(); - gtk_css_provider_load_from_data(css, - "#avatar-btn, #hamburger-btn {" - " padding: 0px;" - " min-width: 28px; min-height: 28px;" - " border-radius: 4px;" - "}" - "#avatar-btn image, #hamburger-btn image {" - " padding: 0px; margin: 0px;" - "}", - -1, NULL); - gtk_style_context_add_provider_for_screen( - gdk_screen_get_default(), - GTK_STYLE_PROVIDER(css), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); - g_object_unref(css); + * gtk_widget_set_size_request() in C. + * + * Also apply the sovereign_browser app theme: red accent (#ff0000) + * for the URL entry focus ring, tab active/highlight indicators, and + * button hover/active states — matching the client project's button + * scheme (black border, white bg, red hover border, red active bg). + * Adapts to the theme_dark setting (swaps fg/bg). */ + apply_app_theme(); /* Build a window-level GtkPaned: left = sidebar container, right = * notebook. The sidebar is per-window (not per-tab), so it persists @@ -3271,8 +3339,14 @@ void tab_manager_init(GtkContainer *parent, g_main_window.sidebar_visible = FALSE; /* The main window/notebook are the default active window/notebook - * for MCP get_active_webview(). Updated when auxiliary windows - * gain focus (see on_window_focus_in). */ + * for MCP get_active_webview() and keyboard shortcuts (zoom, etc.). + * Updated when any window gains focus (see on_window_focus_in). + * The main window also needs this handler so that when the user + * clicks back to it from an auxiliary window, g_active_notebook + * reverts to the main notebook — otherwise zoom/shortcuts would + * keep targeting the auxiliary window's tab. */ + g_signal_connect(window, "focus-in-event", + G_CALLBACK(on_window_focus_in), g_notebook); g_active_window = window; g_active_notebook = g_notebook; g_active_ws = &g_main_window; @@ -3284,6 +3358,9 @@ void tab_manager_apply_settings(void) { if (g_notebook == NULL) return; const browser_settings_t *s = settings_get(); + /* Re-apply the GTK app theme (colors adapt to theme_dark). */ + apply_app_theme(); + gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position); /* Apply drag reorder to all existing tabs. */ @@ -3312,6 +3389,10 @@ int tab_manager_new_tab(const char *url) { return -1; } + /* Inject the per-tab performance probe (sovereign://processes). + * Skips internal sovereign:// pages at runtime via the preamble. */ + perf_probe_setup(tab->webview, index); + /* Add to the effective notebook (active window's notebook, or the * main notebook as fallback). This makes Ctrl+T / "New Tab" open in * the focused window instead of always the main window. */ diff --git a/src/version.h b/src/version.h index 3c07207..ffec350 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.46" +#define SB_VERSION "v0.0.47" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 46 +#define SB_VERSION_PATCH 47 #endif /* SOVEREIGN_BROWSER_VERSION_H */ diff --git a/tests/local-site/cpu-hog.html b/tests/local-site/cpu-hog.html new file mode 100644 index 0000000..caead9f --- /dev/null +++ b/tests/local-site/cpu-hog.html @@ -0,0 +1,27 @@ + + +CPU Hog Test + +

CPU Hog Test

+

This page runs a busy loop in setInterval to generate long tasks.

+

+
+
+
diff --git a/www/js/perf-probe.js b/www/js/perf-probe.js
new file mode 100644
index 0000000..2413c22
--- /dev/null
+++ b/www/js/perf-probe.js
@@ -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=
+ *
+ * The tab_index is supplied by the browser via a 
+ * 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);
+})();
diff --git a/www/processes.css b/www/processes.css
new file mode 100644
index 0000000..14f7a02
--- /dev/null
+++ b/www/processes.css
@@ -0,0 +1,152 @@
+/* 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;
+}
+
+/* Heat bar for CPU columns. A colored background behind the numeric
+ * value: green < 10%, yellow < 50%, red >= 50%. */
+.heat {
+  display: inline-block;
+  min-width: 48px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  text-align: right;
+  color: var(--bg);
+  font-weight: bold;
+}
+.heat.cool { background: #4caf50; color: #fff; }
+.heat.warm { background: #ff9800; color: #fff; }
+.heat.hot  { background: #f44336; color: #fff; }
+.heat.cold { background: var(--border); color: var(--muted); }
+
+/* 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; }
diff --git a/www/processes.html b/www/processes.html
new file mode 100644
index 0000000..e6afd9c
--- /dev/null
+++ b/www/processes.html
@@ -0,0 +1,110 @@
+
+
+
+  
+  
+  Processes — Sovereign Browser
+  
+
+
+  

Processes

+ +
+ + +
+ +
+ + +
+

+ All sovereign_browser processes: the main browser, WebKit renderer + / network / GPU processes, and managed Tor/FIPS subprocesses. + CPU% is computed from /proc utime+stime deltas + (first sample is always 0). Renderer rows expand to show their + hosted tabs. +

+ + + + + + + + + + + + + + + + +
PIDNameCPU%RSSPSSThreadsUptimeStateTypeTabs
+
+ + +
+

+ Per-tab performance from the in-page probe. CPU-busy% 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). +

+ + + + + + + + + + + + + + + +
#TitleCPU-busy%FPSTimersNetHeapDOMWPID
+ + + +
+ + + + diff --git a/www/processes.js b/www/processes.js new file mode 100644 index 0000000..54461e2 --- /dev/null +++ b/www/processes.js @@ -0,0 +1,453 @@ +/* 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 || pct <= 0) return 'cold'; + if (pct < 10) return 'cool'; + if (pct < 50) return 'warm'; + return 'hot'; +} + +function heatHtml(pct) { + if (pct == null) return ''; + return '' + + pct.toFixed(1) + ''; +} + +/* ── 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 = '' + tabsCount + ''; + } else { + tabsCell = '—'; + } + tr.innerHTML = + '' + p.pid + '' + + '' + esc(p.name) + '' + + '' + heatHtml(p.cpu_percent) + '' + + '' + fmtKb(p.rss_kb) + '' + + '' + fmtKb(p.pss_kb) + '' + + '' + (p.threads || 0) + '' + + '' + fmtUptime(p.uptime_sec) + '' + + '' + esc(p.state) + '' + + '' + esc(p.ownership) + + (p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '' + + '' + tabsCell + ''; + 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 = ''; + p.hosted_tabs.forEach(function (t) { + html += '#' + + t.index + '' + esc(t.title || '(untitled)') + + ' ' + esc(t.url) + ''; + }); + html += ''; + sub.innerHTML = html; + body.appendChild(sub); + } + }); + + if (sorted.length === 0) { + body.innerHTML = 'No processes.'; + } +} + +async function fetchProcesses() { + try { + var data = await sovereignGet('sovereign://processes/list'); + 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 = + '' + t.index + '' + + '' + + esc(t.title || '(untitled)') + '' + + (t.is_internal ? ' (internal)' : '') + + '
' + esc(t.url) + '' + + '' + (t.is_internal ? '' + : heatHtml(busy)) + '' + + '' + probeField(t, 'fps') + '' + + '' + probeField(t, 'timer_count') + '' + + '' + probeField(t, 'net_in_flight') + '' + + '' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '' + + '' + probeField(t, 'dom_node_count') + '' + + '' + (t.webprocess_pid || 0) + ''; + (function (idx) { + tr.addEventListener('click', function () { openDrilldown(idx); }); + })(t.index); + body.appendChild(tr); + }); + + if (sorted.length === 0) { + body.innerHTML = 'No tabs.'; + } +} + +async function fetchTabs() { + try { + var data = await sovereignGet('sovereign://processes/tabs'); + 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 = + '

Failed to load drill-down: ' + esc(e.message) + '

'; + } +} + +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 += '
' + r[0] + '' + + r[1] + '
'; + }); + 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 += '' + esc(s.name) + '' + + s.total_ms + '' + s.count + ''; + }); + if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) { + ltBody.innerHTML = 'No long tasks.'; + } + + // Timers. + var tmBody = document.getElementById('dd-timers-body'); + tmBody.innerHTML = ''; + (p.timer_top || []).forEach(function (s) { + tmBody.innerHTML += '' + esc(s.code) + '' + + s.interval_ms + '' + s.count + ''; + }); + if (!p.timer_top || p.timer_top.length === 0) { + tmBody.innerHTML = 'No active timers.'; + } + + // Network endpoints. + var netBody = document.getElementById('dd-net-body'); + netBody.innerHTML = ''; + (p.net_top_endpoints || []).forEach(function (s) { + netBody.innerHTML += '' + esc(s.url) + '' + + s.count + ''; + }); + if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) { + netBody.innerHTML = 'No network requests.'; + } + + // Timeline. + var tl = document.getElementById('dd-timeline'); + tl.innerHTML = ''; + var timeline = p.long_task_timeline || []; + if (timeline.length === 0) { + tl.innerHTML = '
No long tasks in the last 30s.
'; + } 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);