v0.0.13 - Fix Processes tab: read /proc for browser and child processes, add swap column, align tab/probe JSON with processes.js
This commit is contained in:
Generated
+2
-1
@@ -3055,7 +3055,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sovereign_browser"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3075,6 +3075,7 @@ dependencies = [
|
||||
"image",
|
||||
"javascriptcore-rs",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ members = [
|
||||
|
||||
[package]
|
||||
name = "sovereign_browser"
|
||||
version = "0.0.12"
|
||||
version = "0.0.13"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
|
||||
@@ -69,6 +69,7 @@ env_logger = "0.11"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
once_cell = "1"
|
||||
libc = "0.2"
|
||||
lazy_static = "1"
|
||||
regex = "1"
|
||||
qrcode = "0.14"
|
||||
|
||||
@@ -124,7 +124,7 @@ The `www/settings.html` + `settings.js` expects the `settings/config` JSON to in
|
||||
| `sovereign://bookmarks` | Tree view with add/delete/create/move/rename | ✅ Implemented ([`src/nostr_bridge.rs:95`](src/nostr_bridge.rs:95)) |
|
||||
| `sovereign://profile` | Kind 0/3/10002 data from SQLite | ✅ Implemented ([`src/nostr_bridge.rs:386`](src/nostr_bridge.rs:386)) |
|
||||
| `sovereign://processes` | Layer 1 (OS) + Layer 2 (tabs) | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) |
|
||||
| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Served from `www/fips.html` + JS, backend stubs |
|
||||
| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Implemented — page from `www/fips.html` + JS, API in [`src/fips_api.rs`](src/fips_api.rs) |
|
||||
| `sovereign://agents` | Provider config, models, skills | ✅ Served from `www/agents/config.html` + JS |
|
||||
| `sovereign://agents/chat` | Full chat client with markdown, conversations | ✅ Served from `www/agents/chat.html` + JS |
|
||||
| `sovereign://qr` | QR code generation | ✅ Implemented ([`src/nostr_bridge.rs:419`](src/nostr_bridge.rs:419)) |
|
||||
@@ -137,9 +137,28 @@ The `www/settings.html` + `settings.js` expects the `settings/config` JSON to in
|
||||
|
||||
| Service | C Status | Rust Status |
|
||||
|---------|----------|-------------|
|
||||
| **Tor control** | TCP/Unix socket, authenticate, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) |
|
||||
| **FIPS control** | Unix socket JSON-line protocol | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) |
|
||||
| **Net services** | Start/stop Tor & FIPS managed services | Stubs ([`src/net_services.rs`](src/net_services.rs)) |
|
||||
| **Tor control** | TCP/Unix socket, cookie/password/null auth, multi-line replies, bootstrap poll, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) |
|
||||
| **FIPS control** | Unix socket JSON-line protocol, socket discovery | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) |
|
||||
| **Net services** | Attach to running daemon, else spawn/supervise managed Tor & FIPS; autostart; clean shutdown | ✅ Implemented ([`src/net_services.rs`](src/net_services.rs)) |
|
||||
| **`fips://` URLs** | Normalize to `http://<host>.fips` (URL bar, links, agent `open`) | ✅ Implemented ([`src/search.rs`](src/search.rs), unit-tested) |
|
||||
| **`.onion` / `tor://`** | Route via Tor SOCKS, wait for bootstrap, full error text | ✅ Implemented ([`src/tor_scheme.rs`](src/tor_scheme.rs)) |
|
||||
|
||||
### Network services: behaviour and known limits
|
||||
|
||||
- **Modes** (`tor_mode` / `fips_mode`): `auto` (attach, else manage), `attach`, `manage`.
|
||||
Settable via `sovereign://settings/set?key=...`; other keys: `*_binary_path`,
|
||||
`tor_attach_socks`, `tor_attach_control`, `tor_data_dir`, `fips_control_socket`, `fips_config_dir`.
|
||||
- **Managed Tor** uses a loopback TCP `SocksPort` (so `reqwest` can use it) and a Unix
|
||||
`ControlPort` with cookie auth. `__OwningControllerProcess` makes Tor exit if the browser is
|
||||
SIGKILLed; SIGTERM/SIGINT/normal quit stop it gracefully (SIGTERM, then SIGKILL after 5s).
|
||||
- **Attached Tor on a Unix SOCKS socket** (`unix:/run/tor/socks`) is bridged through a
|
||||
loopback TCP forwarder because `reqwest` cannot dial Unix sockets.
|
||||
- **Managed FIPS** needs `CAP_NET_ADMIN` (or root) on the `fips` binary; the browser refuses
|
||||
to start it otherwise. Attached daemons are never stopped.
|
||||
- **Tor control on the Debian system Tor**: `/run/tor/control.authcookie` is only readable by
|
||||
the `debian-tor` group, so the browser attaches for SOCKS only and skips bootstrap polling.
|
||||
- **Not yet done**: NEWNYM / circuit info in the UI, Tor password auth from settings, a
|
||||
settings-page UI for the new Tor/FIPS keys.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ This document provides a detailed breakdown of all components, subsystems, inter
|
||||
| **URL Autocomplete / Search Dropdown** | Complete (GtkEntryCompletion for bookmarks & history) | Incomplete (URL entry exists, completion model not wired) | **20%** | **Medium** |
|
||||
| **Per-Tab Performance Probe (`sovereign://processes`)** | Complete (`perf-probe.js` injection, CPU/FPS/DOM stats, probe-report endpoint) | Incomplete (Skeleton probe struct, probe injection & report collection missing) | **15%** | **Medium** |
|
||||
| **NIP-78 Settings & Bookmark Sync** | Complete (Kind 30078 / 30003 NIP-44 encrypted sync to bootstrap relays) | Partial (Library functions exist, automatic background publish not wired) | **40%** | **Medium** |
|
||||
| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Stubs (Module placeholders returning mock/default status) | **15%** | **Low** |
|
||||
| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Implemented (attach/manage/supervise, control clients, `sovereign://fips/*` API, `fips://` URLs) — see `plans/final-gap-analysis.md` §6 | **90%** | **Low** |
|
||||
| **Offline Site Downloader** | Complete (Headless webview rendering & asset bundling) | Stubs (Log message placeholder) | **10%** | **Low** |
|
||||
|
||||
---
|
||||
@@ -88,10 +88,10 @@ In the original C version, the `www/` directory contains complete single-page we
|
||||
|
||||
1. **Tor Control**:
|
||||
- **C**: Connects to Tor ControlPort (default 9051), authenticates, checks circuit status, and requests new identity (`SIGNAL NEWNYM`).
|
||||
- **Rust**: Simulated in memory.
|
||||
- **Rust**: Implemented (TCP + Unix endpoints, cookie/password auth, bootstrap polling).
|
||||
2. **FIPS Mesh Control**:
|
||||
- **C**: Connects via Unix domain socket to the local FIPS daemon, queries peer tables, resolves `.fips` and npub overlay addresses.
|
||||
- **Rust**: Stubs returning default status.
|
||||
- **Rust**: Implemented (socket discovery, status/peers/tree/identity cache/connect/disconnect, directory query).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
//! `sovereign://fips/*` JSON API consumed by `www/fips.js`.
|
||||
//!
|
||||
//! Port of the FIPS handlers in `nostr_bridge.c` (`handle_fips_*`). Every
|
||||
//! function here blocks on socket or relay I/O, so callers must run
|
||||
//! [`handle`] on a worker thread (see `respond_json_async` in
|
||||
//! `nostr_bridge.rs`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fips_control as fc;
|
||||
use crate::net_services::{self, ServiceState};
|
||||
|
||||
/// Default FIPS advert relays (from fips/src/config/node.rs).
|
||||
const ADVERT_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://nos.lol", "wss://offchain.pub"];
|
||||
/// Kind 37195: parameterized-replaceable FIPS node advert.
|
||||
const ADVERT_KIND: u64 = 37195;
|
||||
const ADVERT_D_TAG: &str = "fips-overlay-v1";
|
||||
const DIRECTORY_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
/// Dispatch `sovereign://fips/<route>?<query>`.
|
||||
pub fn handle(route: &str, query: &str) -> Value {
|
||||
match route {
|
||||
"status" => net_services::net_services_status_json(),
|
||||
"peers" => peers(),
|
||||
"tree" => tree(),
|
||||
"network" => network(),
|
||||
"directory" => directory(),
|
||||
"connect" => connect(query),
|
||||
"disconnect" => disconnect(query),
|
||||
"restart" => restart(),
|
||||
_ => json!({ "error": "Not found" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(query: &str, key: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| {
|
||||
urlencoding::decode(&v.replace('+', " "))
|
||||
.map(|d| d.into_owned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Some(error JSON)` unless the FIPS service is ready.
|
||||
fn require_ready() -> Option<Value> {
|
||||
if net_services::net_services_fips_status().state == ServiceState::Ready {
|
||||
None
|
||||
} else {
|
||||
Some(json!({ "error": "FIPS not ready" }))
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_npubs(peers: &Value) -> HashSet<String> {
|
||||
peers
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| p.get("npub").and_then(|n| n.as_str()))
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn peers() -> Value {
|
||||
if let Some(e) = require_ready() {
|
||||
return json!({ "peers": [], "error": e["error"] });
|
||||
}
|
||||
match fc::fips_control_show_peers() {
|
||||
Ok(peers) => json!({ "peers": peers }),
|
||||
Err(e) => json!({ "peers": [], "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn tree() -> Value {
|
||||
if let Some(e) = require_ready() {
|
||||
return e;
|
||||
}
|
||||
match fc::fips_control_show_tree() {
|
||||
Ok(tree) if tree.is_object() => json!({ "tree": tree }),
|
||||
Ok(_) => json!({ "error": "invalid tree JSON" }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined network view: mesh summary, known nodes (flagging direct
|
||||
/// peers) and the spanning tree.
|
||||
fn network() -> Value {
|
||||
if let Some(e) = require_ready() {
|
||||
return e;
|
||||
}
|
||||
let mut root = json!({});
|
||||
let mut summary = json!({});
|
||||
|
||||
match fc::fips_control_status() {
|
||||
Ok(st) => {
|
||||
root["peer_count"] = json!(st.peer_count);
|
||||
summary["node_npub"] = json!(st.npub);
|
||||
summary["daemon_state"] = json!(st.state);
|
||||
summary["tree_state"] = json!(st.tree_state);
|
||||
summary["tun_name"] = json!(st.tun_name);
|
||||
summary["tun_active"] = json!(st.tun_active);
|
||||
}
|
||||
Err(e) => summary["error"] = json!(e.to_string()),
|
||||
}
|
||||
|
||||
let direct = fc::fips_control_show_peers()
|
||||
.map(|p| peer_npubs(&p))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut nodes: Vec<Value> = Vec::new();
|
||||
if let Ok(cache) = fc::fips_control_show_identity_cache() {
|
||||
if let Some(entries) = cache.get("entries").and_then(|e| e.as_array()) {
|
||||
for entry in entries {
|
||||
let Some(npub) = entry.get("npub").and_then(|n| n.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut node = entry.clone();
|
||||
node["is_direct_peer"] = json!(direct.contains(npub));
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
if let Some(count) = cache.get("count").and_then(|c| c.as_i64()) {
|
||||
summary["known_nodes"] = json!(count);
|
||||
}
|
||||
if let Some(max) = cache.get("max_entries").and_then(|c| c.as_i64()) {
|
||||
summary["max_cache_entries"] = json!(max);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(tree) = fc::fips_control_show_tree() {
|
||||
if tree.is_object() {
|
||||
if let Some(v) = tree.get("root_npub").filter(|v| v.is_string()) {
|
||||
summary["root_npub"] = v.clone();
|
||||
}
|
||||
if let Some(v) = tree.get("is_root").filter(|v| v.is_boolean()) {
|
||||
summary["is_root"] = v.clone();
|
||||
}
|
||||
if let Some(v) = tree.get("depth").filter(|v| v.is_number()) {
|
||||
summary["tree_depth"] = v.clone();
|
||||
}
|
||||
if let Some(v) = tree.get("parent_display_name").filter(|v| v.is_string()) {
|
||||
summary["parent_display_name"] = v.clone();
|
||||
}
|
||||
root["tree"] = tree;
|
||||
}
|
||||
}
|
||||
|
||||
root["summary"] = summary;
|
||||
root["nodes"] = json!(nodes);
|
||||
root
|
||||
}
|
||||
|
||||
fn connect(query: &str) -> Value {
|
||||
let npub = param(query, "npub");
|
||||
let address = param(query, "address");
|
||||
let transport = param(query, "transport");
|
||||
if npub.is_empty() || address.is_empty() {
|
||||
return json!({ "error": "npub and address are required" });
|
||||
}
|
||||
match fc::fips_control_connect_peer(&npub, &address, &transport) {
|
||||
Ok(()) => json!({ "status": "ok" }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(query: &str) -> Value {
|
||||
let npub = param(query, "npub");
|
||||
if npub.is_empty() {
|
||||
return json!({ "error": "npub is required" });
|
||||
}
|
||||
match fc::fips_control_disconnect_peer(&npub) {
|
||||
Ok(()) => json!({ "status": "ok" }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn restart() -> Value {
|
||||
match net_services::net_services_restart_fips() {
|
||||
Ok(()) => json!({ "status": "ok" }),
|
||||
Err(e) => json!({ "error": format!("FIPS restart failed: {}", e) }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the FIPS advert relays for Kind 37195 / `fips-overlay-v1` events
|
||||
/// (the global node directory) and cross-reference the local daemon's
|
||||
/// direct peers. May take a few seconds.
|
||||
fn directory() -> Value {
|
||||
let direct = if net_services::net_services_fips_status().state == ServiceState::Ready {
|
||||
fc::fips_control_show_peers()
|
||||
.map(|p| peer_npubs(&p))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let events = match fetch_adverts() {
|
||||
Ok(e) => e,
|
||||
Err(e) => return json!({ "nodes": [], "count": 0, "error": e }),
|
||||
};
|
||||
|
||||
// Keep the newest advert per author.
|
||||
let mut newest: std::collections::HashMap<String, nostr_core::Event> =
|
||||
std::collections::HashMap::new();
|
||||
for ev in events {
|
||||
let has_d = ev
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.0.first().map(String::as_str) == Some("d")
|
||||
&& t.0.get(1).map(String::as_str) == Some(ADVERT_D_TAG));
|
||||
if !has_d {
|
||||
continue;
|
||||
}
|
||||
let key = ev.pubkey.to_hex();
|
||||
match newest.get(&key) {
|
||||
Some(existing) if existing.created_at >= ev.created_at => {}
|
||||
_ => {
|
||||
newest.insert(key, ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut nodes: Vec<Value> = newest
|
||||
.into_values()
|
||||
.map(|ev| {
|
||||
let npub = nostr_core::util::bech32::npub_encode(&ev.pubkey).unwrap_or_default();
|
||||
let mut node = json!({
|
||||
"npub": npub,
|
||||
"pubkey_hex": ev.pubkey.to_hex(),
|
||||
"created_at": ev.created_at,
|
||||
"is_direct_peer": direct.contains(&npub),
|
||||
});
|
||||
if let Ok(advert) = serde_json::from_str::<Value>(&ev.content) {
|
||||
if let Some(v) = advert.get("endpoints").filter(|v| v.is_array()) {
|
||||
node["endpoints"] = v.clone();
|
||||
}
|
||||
if let Some(v) = advert.get("signalRelays").filter(|v| v.is_array()) {
|
||||
node["signal_relays"] = v.clone();
|
||||
}
|
||||
}
|
||||
node
|
||||
})
|
||||
.collect();
|
||||
nodes.sort_by(|a, b| b["created_at"].as_u64().cmp(&a["created_at"].as_u64()));
|
||||
|
||||
json!({ "count": nodes.len(), "nodes": nodes })
|
||||
}
|
||||
|
||||
fn fetch_adverts() -> Result<Vec<nostr_core::Event>, String> {
|
||||
use nostr_relay::pool::{ReconnectConfig, RelayPool};
|
||||
use std::sync::Arc;
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
|
||||
rt.block_on(async {
|
||||
let pool = Arc::new(RelayPool::new(Some(ReconnectConfig::default())));
|
||||
for url in ADVERT_RELAYS {
|
||||
let _ = pool.add_relay(url).await;
|
||||
}
|
||||
pool.connect_all_with_timeout(5_000).await;
|
||||
let connected = pool.connected_relay_urls().await;
|
||||
if connected.is_empty() {
|
||||
return Err("no advert relays reachable".to_string());
|
||||
}
|
||||
|
||||
let pool_for_loop = pool.clone();
|
||||
let event_loop = tokio::spawn(async move {
|
||||
pool_for_loop.run(100).await;
|
||||
});
|
||||
|
||||
let filter = nostr_core::Filter::new().kinds(vec![ADVERT_KIND]).limit(500);
|
||||
let result = pool.query_sync(&connected, filter, DIRECTORY_TIMEOUT_MS).await;
|
||||
event_loop.abort();
|
||||
pool.disconnect_all().await;
|
||||
result.map_err(|e| format!("relay query failed: {:?}", e))
|
||||
})
|
||||
}
|
||||
+189
-140
@@ -2,193 +2,242 @@
|
||||
//!
|
||||
//! Port of `fips_control.c` / `fips_control.h` from the C project.
|
||||
//! Implements a synchronous FIPS JSON-line control client over a Unix
|
||||
//! domain socket. Requests are `{"command": "..."}` JSON lines; responses
|
||||
//! are `{"status": "ok", "data": {...}}` JSON lines.
|
||||
//! domain socket. Requests are `{"command": "...", "params": {...}}` JSON
|
||||
//! lines; responses are `{"status": "ok", "data": {...}}` JSON lines.
|
||||
//!
|
||||
//! The FIPS daemon closes the control connection after each response, so
|
||||
//! every request opens a fresh connection.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const FIPS_RESPONSE_MAX: usize = 1024 * 1024;
|
||||
const FIPS_RESPONSE_MAX: u64 = 1024 * 1024;
|
||||
const FIPS_TIMEOUT_MS: u64 = 1500;
|
||||
|
||||
/// FIPS control state.
|
||||
static G_FIPS_CONTROL: Lazy<Mutex<FipsControlState>> = Lazy::new(|| Mutex::new(FipsControlState::default()));
|
||||
/// Well-known control socket locations, in discovery order (after any
|
||||
/// explicitly configured path). Mirrors `net_service_enable()` in C.
|
||||
const FIPS_SOCKET_CANDIDATES: &[&str] = &["/run/fips/control.sock", "/tmp/fips-control.sock"];
|
||||
|
||||
struct FipsControlState {
|
||||
running: bool,
|
||||
fips_path: String,
|
||||
socket_path: String,
|
||||
/// The control socket the browser currently talks to. Empty until a
|
||||
/// service attach/spawn resolves one.
|
||||
static G_FIPS_SOCKET: Lazy<Mutex<String>> = Lazy::new(|| Mutex::new(String::new()));
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// Snapshot of `show_status`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FipsStatus {
|
||||
pub npub: String,
|
||||
pub tun_name: String,
|
||||
pub tun_active: bool,
|
||||
pub state: String,
|
||||
pub tree_state: String,
|
||||
pub peer_count: i64,
|
||||
}
|
||||
|
||||
impl Default for FipsControlState {
|
||||
fn default() -> Self {
|
||||
FipsControlState {
|
||||
running: false,
|
||||
fips_path: String::new(),
|
||||
socket_path: "/tmp/fips_control.sock".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FIPS control.
|
||||
/// Initialize FIPS control (forgets any previously resolved socket).
|
||||
pub fn fips_control_init() {
|
||||
let mut state = G_FIPS_CONTROL.lock().unwrap();
|
||||
state.running = false;
|
||||
G_FIPS_SOCKET.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Start the FIPS mesh node.
|
||||
pub fn fips_control_start() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut state = G_FIPS_CONTROL.lock().unwrap();
|
||||
if state.running {
|
||||
return Ok(());
|
||||
/// Set the control socket path used by all subsequent requests.
|
||||
pub fn fips_control_set_socket(path: &str) {
|
||||
*G_FIPS_SOCKET.lock().unwrap() = path.to_string();
|
||||
}
|
||||
|
||||
/// The control socket path currently in use (empty if none).
|
||||
pub fn fips_control_socket() -> String {
|
||||
G_FIPS_SOCKET.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Connect to a control socket with read/write timeouts applied.
|
||||
fn connect(path: &str) -> Result<UnixStream> {
|
||||
if path.is_empty() {
|
||||
return Err("empty FIPS control socket path".into());
|
||||
}
|
||||
// Try to connect to the FIPS control socket to verify it's running.
|
||||
let socket_path = state.socket_path.clone();
|
||||
if UnixStream::connect(&socket_path).is_ok() {
|
||||
state.running = true;
|
||||
println!("[fips] FIPS mesh node connected via {}", socket_path);
|
||||
Ok(())
|
||||
} else {
|
||||
// FIPS daemon not running — mark as not running.
|
||||
println!("[fips] FIPS control socket {} not available", socket_path);
|
||||
Err(format!("FIPS control socket {} not available", socket_path).into())
|
||||
let stream = UnixStream::connect(path)
|
||||
.map_err(|e| format!("FIPS connect failed ({}): {}", path, e))?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// True if something accepts connections on `path`.
|
||||
pub fn fips_control_socket_available(path: &str) -> bool {
|
||||
!path.is_empty() && Path::new(path).exists() && UnixStream::connect(path).is_ok()
|
||||
}
|
||||
|
||||
/// Find a reachable FIPS control socket: the configured path first, then
|
||||
/// `/run/fips/control.sock`, `$XDG_RUNTIME_DIR/fips/control.sock` and
|
||||
/// `/tmp/fips-control.sock`.
|
||||
pub fn fips_control_discover(configured: &str) -> Option<String> {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
if !configured.is_empty() {
|
||||
candidates.push(configured.to_string());
|
||||
}
|
||||
candidates.push(FIPS_SOCKET_CANDIDATES[0].to_string());
|
||||
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
candidates.push(format!("{}/fips/control.sock", xdg));
|
||||
}
|
||||
}
|
||||
candidates.push(FIPS_SOCKET_CANDIDATES[1].to_string());
|
||||
candidates.into_iter().find(|p| fips_control_socket_available(p))
|
||||
}
|
||||
|
||||
/// Stop the FIPS mesh node.
|
||||
pub fn fips_control_stop() {
|
||||
let mut state = G_FIPS_CONTROL.lock().unwrap();
|
||||
state.running = false;
|
||||
println!("[fips] FIPS mesh node stopped");
|
||||
}
|
||||
/// Send one command on a fresh connection to `socket` and return the
|
||||
/// response's `data` field.
|
||||
fn request_on(socket: &str, command: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let mut stream = connect(socket)?;
|
||||
|
||||
/// Check if FIPS is running.
|
||||
pub fn fips_control_is_running() -> bool {
|
||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
||||
state.running
|
||||
}
|
||||
|
||||
/// Send a JSON-line command to the FIPS control socket and return the
|
||||
/// parsed response JSON.
|
||||
fn fips_request(command: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
||||
let socket_path = state.socket_path.clone();
|
||||
drop(state);
|
||||
|
||||
let mut stream = UnixStream::connect(&socket_path)?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
|
||||
// Send the command as a JSON line.
|
||||
let request = serde_json::json!({ "command": command });
|
||||
let line = format!("{}\n", serde_json::to_string(&request)?);
|
||||
let mut req = serde_json::json!({ "command": command });
|
||||
if let Some(p) = params {
|
||||
req["params"] = p;
|
||||
}
|
||||
let line = format!("{}\n", serde_json::to_string(&req)?);
|
||||
stream.write_all(line.as_bytes())?;
|
||||
|
||||
// Read the response (until newline).
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut reader = BufReader::new(stream.take(FIPS_RESPONSE_MAX));
|
||||
let mut response = String::new();
|
||||
let n = reader.read_line(&mut response)?;
|
||||
let n = reader.read_line(&mut response).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut {
|
||||
"FIPS response timed out".to_string()
|
||||
} else {
|
||||
format!("FIPS read failed: {}", e)
|
||||
}
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err("FIPS closed control connection".into());
|
||||
}
|
||||
|
||||
let root: serde_json::Value = serde_json::from_str(&response)?;
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(response.trim()).map_err(|_| "invalid FIPS JSON response")?;
|
||||
if root.get("status").and_then(|s| s.as_str()) != Some("ok") {
|
||||
let msg = root.get("message").and_then(|m| m.as_str()).unwrap_or("request failed");
|
||||
let msg = root
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("request failed");
|
||||
return Err(format!("FIPS control error: {}", msg).into());
|
||||
}
|
||||
Ok(root)
|
||||
Ok(root.get("data").cloned().unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
/// Get the FIPS status.
|
||||
pub fn fips_control_show_status() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let root = fips_request("show_status")?;
|
||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
||||
/// Send a command using the current control socket.
|
||||
fn request(command: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let socket = fips_control_socket();
|
||||
if socket.is_empty() {
|
||||
return Err("FIPS control socket unavailable".into());
|
||||
}
|
||||
request_on(&socket, command, params)
|
||||
}
|
||||
|
||||
/// Get the FIPS peer list.
|
||||
pub fn fips_control_show_peers() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let root = fips_request("show_peers")?;
|
||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
||||
/// Parse a `show_status` payload.
|
||||
fn parse_status(data: &serde_json::Value) -> FipsStatus {
|
||||
let s = |k: &str| data.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let tun_state = s("tun_state");
|
||||
let mut tree_state = s("tree_state");
|
||||
if tree_state.is_empty() {
|
||||
tree_state = s("tree");
|
||||
}
|
||||
FipsStatus {
|
||||
npub: s("npub"),
|
||||
tun_name: s("tun_name"),
|
||||
tun_active: tun_state == "active" || tun_state == "up",
|
||||
state: s("state"),
|
||||
tree_state,
|
||||
peer_count: data.get("peer_count").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query `show_status` on a specific socket (used while attaching, before
|
||||
/// the socket becomes the current one).
|
||||
pub fn fips_control_status_on(socket: &str) -> Result<FipsStatus> {
|
||||
let data = request_on(socket, "show_status", None)?;
|
||||
if !data.is_object() {
|
||||
return Err("FIPS status response lacks data".into());
|
||||
}
|
||||
Ok(parse_status(&data))
|
||||
}
|
||||
|
||||
/// Get the typed FIPS status from the current socket.
|
||||
pub fn fips_control_status() -> Result<FipsStatus> {
|
||||
let data = request("show_status", None)?;
|
||||
if !data.is_object() {
|
||||
return Err("FIPS status response lacks data".into());
|
||||
}
|
||||
Ok(parse_status(&data))
|
||||
}
|
||||
|
||||
/// Get the raw FIPS `show_status` data.
|
||||
pub fn fips_control_show_status() -> Result<serde_json::Value> {
|
||||
request("show_status", None)
|
||||
}
|
||||
|
||||
/// Get the FIPS peer list. Normalizes both response shapes (a bare array
|
||||
/// or `{"peers": [...]}`) to a JSON array.
|
||||
pub fn fips_control_show_peers() -> Result<serde_json::Value> {
|
||||
let data = request("show_peers", None)?;
|
||||
Ok(match data {
|
||||
serde_json::Value::Array(_) => data,
|
||||
serde_json::Value::Object(ref o) => o
|
||||
.get("peers")
|
||||
.cloned()
|
||||
.filter(|p| p.is_array())
|
||||
.unwrap_or_else(|| serde_json::json!([])),
|
||||
_ => serde_json::json!([]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the FIPS spanning tree.
|
||||
pub fn fips_control_show_tree() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let root = fips_request("show_tree")?;
|
||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
||||
pub fn fips_control_show_tree() -> Result<serde_json::Value> {
|
||||
request("show_tree", None)
|
||||
}
|
||||
|
||||
/// Get the FIPS identity cache.
|
||||
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let root = fips_request("show_identity_cache")?;
|
||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
||||
/// Get the FIPS identity cache (`{"entries": [...], "count": N, ...}`).
|
||||
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value> {
|
||||
request("show_identity_cache", None)
|
||||
}
|
||||
|
||||
/// Connect to a FIPS peer.
|
||||
pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
||||
let socket_path = state.socket_path.clone();
|
||||
drop(state);
|
||||
|
||||
let mut stream = UnixStream::connect(&socket_path)?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
|
||||
let request = serde_json::json!({
|
||||
"command": "connect_peer",
|
||||
pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<()> {
|
||||
if npub.is_empty() || address.is_empty() {
|
||||
return Err("FIPS peer npub and address are required".into());
|
||||
}
|
||||
let transport = if transport.is_empty() { "udp" } else { transport };
|
||||
request(
|
||||
"connect",
|
||||
Some(serde_json::json!({
|
||||
"npub": npub,
|
||||
"address": address,
|
||||
"transport": transport,
|
||||
});
|
||||
let line = format!("{}\n", serde_json::to_string(&request)?);
|
||||
stream.write_all(line.as_bytes())?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(root)
|
||||
})),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from a FIPS peer.
|
||||
pub fn fips_control_disconnect_peer(npub: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
||||
let socket_path = state.socket_path.clone();
|
||||
drop(state);
|
||||
|
||||
let mut stream = UnixStream::connect(&socket_path)?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
||||
|
||||
let request = serde_json::json!({
|
||||
"command": "disconnect_peer",
|
||||
"npub": npub,
|
||||
});
|
||||
let line = format!("{}\n", serde_json::to_string(&request)?);
|
||||
stream.write_all(line.as_bytes())?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(root)
|
||||
pub fn fips_control_disconnect_peer(npub: &str) -> Result<()> {
|
||||
if npub.is_empty() {
|
||||
return Err("FIPS peer npub is required".into());
|
||||
}
|
||||
request("disconnect", Some(serde_json::json!({ "npub": npub })))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a FIPS address (npub) to a reachable endpoint.
|
||||
/// Resolve a FIPS address (npub) to a reachable endpoint, using the
|
||||
/// identity cache.
|
||||
#[allow(dead_code)]
|
||||
pub fn fips_control_resolve(npub: &str) -> Option<String> {
|
||||
// Query the identity cache for the npub's address.
|
||||
if let Ok(cache) = fips_control_show_identity_cache() {
|
||||
if let Some(nodes) = cache.get("nodes").and_then(|n| n.as_array()) {
|
||||
for node in nodes {
|
||||
if node.get("npub").and_then(|n| n.as_str()) == Some(npub) {
|
||||
if let Some(addr) = node.get("address").and_then(|a| a.as_str()) {
|
||||
return Some(addr.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
let cache = fips_control_show_identity_cache().ok()?;
|
||||
let entries = cache.get("entries").and_then(|n| n.as_array())?;
|
||||
entries
|
||||
.iter()
|
||||
.find(|e| e.get("npub").and_then(|n| n.as_str()) == Some(npub))
|
||||
.and_then(|e| e.get("address").and_then(|a| a.as_str()))
|
||||
.map(|a| a.to_string())
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub mod net_services;
|
||||
pub mod tor_control;
|
||||
pub mod tor_scheme;
|
||||
pub mod fips_control;
|
||||
pub mod fips_api;
|
||||
pub mod web_context;
|
||||
pub mod webkit_data;
|
||||
pub mod site_downloader;
|
||||
|
||||
+22
@@ -46,6 +46,7 @@ mod net_services;
|
||||
mod tor_control;
|
||||
mod tor_scheme;
|
||||
mod fips_control;
|
||||
mod fips_api;
|
||||
mod web_context;
|
||||
mod webkit_data;
|
||||
mod site_downloader;
|
||||
@@ -339,6 +340,9 @@ fn main() {
|
||||
if args.no_login {
|
||||
println!("[login] No-login mode (browsing without Nostr identity)");
|
||||
menu::app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true);
|
||||
// Other login paths load saved settings; do the same here so Tor /
|
||||
// FIPS autostart (and everything else) honours the stored values.
|
||||
settings::settings_load();
|
||||
} else if args.login_method.is_some() {
|
||||
// Auto-login from CLI args (no dialog).
|
||||
if !do_cli_login(&args) {
|
||||
@@ -351,6 +355,10 @@ fn main() {
|
||||
do_login(&window);
|
||||
}
|
||||
|
||||
// Bring up Tor / FIPS according to the (now loaded) user settings:
|
||||
// attach to running daemons, or spawn managed ones. Non-blocking.
|
||||
net_services::net_services_autostart();
|
||||
|
||||
// Now build the UI. Each tab carries its own toolbar (URL entry,
|
||||
// back/forward/refresh, bookmark, hamburger menu), so there is no
|
||||
// shared top-level toolbar.
|
||||
@@ -434,6 +442,20 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Quit cleanly on SIGTERM / SIGINT so managed Tor/FIPS processes are
|
||||
// stopped below instead of being orphaned.
|
||||
for sig in [15 /* SIGTERM */, 2 /* SIGINT */] {
|
||||
glib::unix_signal_add_local(sig, || {
|
||||
println!("[browser] Termination signal received; shutting down");
|
||||
session::session_save();
|
||||
gtk::main_quit();
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
|
||||
println!("[browser] Starting GTK main loop...");
|
||||
gtk::main();
|
||||
|
||||
// Stop managed Tor/FIPS processes (attached daemons are left alone).
|
||||
net_services::net_services_shutdown();
|
||||
}
|
||||
|
||||
+1108
-113
File diff suppressed because it is too large
Load Diff
+59
-7
@@ -101,6 +101,15 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── FIPS mesh API (status/peers/network/tree/directory/...) ────
|
||||
// Blocks on daemon sockets / relays, so it runs on a worker thread.
|
||||
if let Some(fips_route) = route.strip_prefix("fips/") {
|
||||
let fips_route = fips_route.to_string();
|
||||
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("").to_string();
|
||||
respond_json_async(request, move || crate::fips_api::handle(&fips_route, &query));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Processes REST API ─────────────────────────────────────────
|
||||
if route.starts_with("processes/") {
|
||||
handle_processes_api(request, &route[10..]);
|
||||
@@ -428,19 +437,27 @@ fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
|
||||
|
||||
match route {
|
||||
"list" => {
|
||||
// Layer 1: OS process list (simplified — just browser + webprocess).
|
||||
let processes = crate::process_info::process_info_get_all();
|
||||
respond_json(request, &serde_json::to_string(&processes).unwrap_or_default());
|
||||
// Layer 1: OS process list (browser + all descendants, from /proc).
|
||||
// Reads /proc for every process on the system, so do it off the
|
||||
// GTK main thread.
|
||||
respond_json_async(request, || {
|
||||
serde_json::to_value(crate::process_info::process_info_get_all())
|
||||
.unwrap_or_else(|_| serde_json::json!([]))
|
||||
});
|
||||
}
|
||||
"tabs" => {
|
||||
// Layer 2: per-tab list with probe data.
|
||||
// Layer 2: per-tab list with probe data (probe is null unless
|
||||
// the perf probe is enabled in settings).
|
||||
let tabs = crate::tab_manager::tab_manager_get_tabs();
|
||||
let tab_infos: Vec<serde_json::Value> = tabs.iter().map(|t| {
|
||||
serde_json::json!({
|
||||
"index": t.id,
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"url": t.url,
|
||||
"is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"),
|
||||
"webprocess_pid": 0,
|
||||
"probe": crate::perf_probe::perf_probe_get_report(t.id),
|
||||
})
|
||||
}).collect();
|
||||
respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default());
|
||||
@@ -461,10 +478,19 @@ fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
|
||||
"tab_probe" => {
|
||||
// Drill-down for a single tab.
|
||||
let index = extract_query_param(query, "index")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let report = crate::perf_probe::perf_probe_get_report(index);
|
||||
respond_json(request, &serde_json::to_string(&report).unwrap_or_default());
|
||||
let tab = crate::tab_manager::tab_manager_get_tabs()
|
||||
.into_iter()
|
||||
.find(|t| t.id == index);
|
||||
let out = serde_json::json!({
|
||||
"index": index,
|
||||
"title": tab.as_ref().map(|t| t.title.clone()).unwrap_or_default(),
|
||||
"url": tab.as_ref().map(|t| t.url.clone()).unwrap_or_default(),
|
||||
"webprocess_pid": 0,
|
||||
"probe": crate::perf_probe::perf_probe_get_report(index),
|
||||
});
|
||||
respond_json(request, &out.to_string());
|
||||
}
|
||||
"tab_action" => {
|
||||
// ?index=N&action=reload|suspend|close
|
||||
@@ -971,6 +997,21 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) {
|
||||
settings::settings_save();
|
||||
false
|
||||
}
|
||||
"tor_mode" | "fips_mode" => {
|
||||
if !matches!(value.as_str(), "auto" | "attach" | "manage") {
|
||||
respond_error_json(request, 400, "Mode must be auto, attach or manage");
|
||||
return;
|
||||
}
|
||||
settings::settings_update(&serde_json::json!({ key.as_str(): value }));
|
||||
settings::settings_save();
|
||||
false
|
||||
}
|
||||
"tor_binary_path" | "tor_attach_socks" | "tor_attach_control" | "tor_data_dir"
|
||||
| "fips_binary_path" | "fips_control_socket" | "fips_config_dir" => {
|
||||
settings::settings_update(&serde_json::json!({ key.as_str(): value }));
|
||||
settings::settings_save();
|
||||
false
|
||||
}
|
||||
"search_engine" => {
|
||||
let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave");
|
||||
if !valid { respond_error_json(request, 400, "Unknown search engine"); return; }
|
||||
@@ -1036,6 +1077,17 @@ fn handle_settings_config_json(request: &URISchemeRequest) {
|
||||
"file_access": true,
|
||||
"universal_access": true,
|
||||
"perf_probe_enabled": s.perf_probe_enabled,
|
||||
"tor_enabled": s.tor_enabled,
|
||||
"tor_mode": s.tor_mode,
|
||||
"tor_binary_path": s.tor_binary_path,
|
||||
"tor_attach_socks": s.tor_attach_socks,
|
||||
"tor_attach_control": s.tor_attach_control,
|
||||
"tor_data_dir": s.tor_data_dir,
|
||||
"fips_enabled": s.fips_enabled,
|
||||
"fips_mode": s.fips_mode,
|
||||
"fips_binary_path": s.fips_binary_path,
|
||||
"fips_control_socket": s.fips_control_socket,
|
||||
"fips_config_dir": s.fips_config_dir,
|
||||
"search_engines": search_engines,
|
||||
"shortcuts": shortcuts,
|
||||
});
|
||||
|
||||
+15
-28
@@ -58,40 +58,27 @@ pub fn perf_probe_page_load_end() {
|
||||
state.page_load_start = None;
|
||||
}
|
||||
|
||||
/// Per-tab probe report received from the injected perf-probe.js.
|
||||
static G_PROBE_REPORTS: Lazy<Mutex<Vec<ProbeReport>>> = Lazy::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
/// A probe report from a single tab.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProbeReport {
|
||||
pub tab_index: i32,
|
||||
pub cpu_busy_percent: f64,
|
||||
pub fps: f64,
|
||||
pub timer_count: u32,
|
||||
pub net_inflight: u32,
|
||||
pub heap_used: f64,
|
||||
pub event_listener_count: u32,
|
||||
pub worker_count: u32,
|
||||
pub dom_node_count: u32,
|
||||
}
|
||||
/// Per-tab probe reports received from the injected perf-probe.js, keyed by
|
||||
/// tab index (tab id). Stored as the raw JSON payload the probe sends so the
|
||||
/// Processes page sees exactly the field names perf-probe.js emits
|
||||
/// (cpu_busy_percent, fps, timer_count, net_in_flight, heap_used_mb, ...).
|
||||
static G_PROBE_REPORTS: Lazy<Mutex<std::collections::HashMap<i32, serde_json::Value>>> =
|
||||
Lazy::new(|| Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
/// Record a probe report from a tab.
|
||||
pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) {
|
||||
let mut reports = G_PROBE_REPORTS.lock().unwrap();
|
||||
// Update or insert the report for this tab index.
|
||||
if let Some(existing) = reports.iter_mut().find(|r| r.tab_index == tab_index) {
|
||||
if let Ok(parsed) = serde_json::from_value::<ProbeReport>(report.clone()) {
|
||||
*existing = parsed;
|
||||
}
|
||||
} else if let Ok(parsed) = serde_json::from_value::<ProbeReport>(report.clone()) {
|
||||
reports.push(parsed);
|
||||
}
|
||||
G_PROBE_REPORTS.lock().unwrap().insert(tab_index, report.clone());
|
||||
}
|
||||
|
||||
/// Get the probe report for a specific tab index.
|
||||
pub fn perf_probe_get_report(index: usize) -> Option<ProbeReport> {
|
||||
let reports = G_PROBE_REPORTS.lock().unwrap();
|
||||
reports.iter().find(|r| r.tab_index as usize == index).cloned()
|
||||
pub fn perf_probe_get_report(index: i32) -> Option<serde_json::Value> {
|
||||
G_PROBE_REPORTS.lock().unwrap().get(&index).cloned()
|
||||
}
|
||||
|
||||
/// Forget the report for a closed tab.
|
||||
#[allow(dead_code)]
|
||||
pub fn perf_probe_clear_report(index: i32) {
|
||||
G_PROBE_REPORTS.lock().unwrap().remove(&index);
|
||||
}
|
||||
|
||||
/// Get current performance metrics.
|
||||
|
||||
+179
-41
@@ -1,61 +1,199 @@
|
||||
//! Process information for the processes page
|
||||
//!
|
||||
//! Port of `process_info.c` / `process_info.h` from the C project.
|
||||
//! Scans `/proc` for the browser process and all of its descendants
|
||||
//! (WebKit web/network/GPU processes, managed Tor/FIPS daemons) and reports
|
||||
//! CPU, memory (RSS / PSS / swap), thread count and uptime for each.
|
||||
//!
|
||||
//! The Processes tab (`www/processes.js`) polls `sovereign://processes/list`
|
||||
//! which serializes the result of [`process_info_get_all`].
|
||||
|
||||
use std::sync::Mutex;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Process info state.
|
||||
static G_PROCESS_INFO: Lazy<Mutex<ProcessInfoState>> = Lazy::new(|| Mutex::new(ProcessInfoState::default()));
|
||||
/// Previous CPU sample per pid: (utime+stime ticks, sample time).
|
||||
static G_CPU_SAMPLES: Lazy<Mutex<HashMap<u32, (u64, Instant)>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
struct ProcessInfoState {
|
||||
web_processes: Vec<WebProcessInfo>,
|
||||
}
|
||||
|
||||
/// Information about a web process.
|
||||
/// Information about one OS process belonging to the browser.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct WebProcessInfo {
|
||||
pub struct ProcInfo {
|
||||
pub pid: u32,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub memory_mb: f64,
|
||||
pub name: String,
|
||||
pub cpu_percent: f64,
|
||||
}
|
||||
|
||||
impl Default for ProcessInfoState {
|
||||
fn default() -> Self {
|
||||
ProcessInfoState {
|
||||
web_processes: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub rss_kb: i64,
|
||||
pub pss_kb: i64,
|
||||
pub swap_kb: i64,
|
||||
pub threads: u32,
|
||||
pub uptime_sec: f64,
|
||||
pub state: String,
|
||||
/// "browser" | "webkit-renderer" | "webkit-network" | "webkit-gpu" |
|
||||
/// "tor" | "fips" | "other"
|
||||
pub ownership: String,
|
||||
pub service_state: Option<String>,
|
||||
/// Tabs hosted by this renderer. WebKitGTK exposes no API mapping a
|
||||
/// WebView to its WebProcess pid, so this is currently always empty.
|
||||
pub hosted_tabs: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Initialize process info.
|
||||
pub fn process_info_init() {
|
||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
||||
state.web_processes.clear();
|
||||
G_CPU_SAMPLES.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Get all web processes.
|
||||
pub fn process_info_get_all() -> Vec<WebProcessInfo> {
|
||||
let state = G_PROCESS_INFO.lock().unwrap();
|
||||
state.web_processes.clone()
|
||||
struct RawStat {
|
||||
ppid: u32,
|
||||
state: String,
|
||||
ticks: u64,
|
||||
threads: u32,
|
||||
start_ticks: u64,
|
||||
}
|
||||
|
||||
/// Add a web process.
|
||||
pub fn process_info_add(pid: u32, url: &str, title: &str) {
|
||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
||||
state.web_processes.push(WebProcessInfo {
|
||||
fn read_stat(pid: u32) -> Option<RawStat> {
|
||||
let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// comm may contain spaces/parens; split after the *last* ')'.
|
||||
let rest = s.rsplit_once(')')?.1;
|
||||
let f: Vec<&str> = rest.split_whitespace().collect();
|
||||
if f.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
Some(RawStat {
|
||||
state: f[0].to_string(),
|
||||
ppid: f[1].parse().ok()?,
|
||||
ticks: f[11].parse::<u64>().ok()? + f[12].parse::<u64>().ok()?,
|
||||
threads: f[17].parse().ok()?,
|
||||
start_ticks: f[19].parse().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_cmdline_name(pid: u32) -> String {
|
||||
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
|
||||
let first = raw.split(|b| *b == 0).next().unwrap_or(&[]);
|
||||
let path = String::from_utf8_lossy(first).to_string();
|
||||
let base = path.rsplit('/').next().unwrap_or("").to_string();
|
||||
if base.is_empty() {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// (rss_kb, pss_kb, swap_kb) from smaps_rollup, falling back to statm.
|
||||
fn read_mem(pid: u32) -> (i64, i64, i64) {
|
||||
let mut rss = -1;
|
||||
let mut pss = -1;
|
||||
let mut swap = 0;
|
||||
if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/smaps_rollup")) {
|
||||
for line in s.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
match (it.next(), it.next().and_then(|v| v.parse::<i64>().ok())) {
|
||||
(Some("Rss:"), Some(v)) => rss = v,
|
||||
(Some("Pss:"), Some(v)) => pss = v,
|
||||
(Some("Swap:"), Some(v)) => swap = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rss < 0 {
|
||||
if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/statm")) {
|
||||
if let Some(pages) = s.split_whitespace().nth(1).and_then(|v| v.parse::<i64>().ok()) {
|
||||
rss = pages * 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
(rss, pss, swap)
|
||||
}
|
||||
|
||||
fn classify(name: &str) -> &'static str {
|
||||
match name {
|
||||
"WebKitWebProcess" => "webkit-renderer",
|
||||
"WebKitNetworkProcess" => "webkit-network",
|
||||
"WebKitGPUProcess" => "webkit-gpu",
|
||||
"tor" => "tor",
|
||||
n if n.contains("fips") => "fips",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the browser process plus all of its descendants.
|
||||
pub fn process_info_get_all() -> Vec<ProcInfo> {
|
||||
let me = std::process::id();
|
||||
let clk_tck = 100.0_f64; // sysconf(_SC_CLK_TCK) is 100 on Linux
|
||||
let sys_uptime: f64 = std::fs::read_to_string("/proc/uptime")
|
||||
.ok()
|
||||
.and_then(|s| s.split_whitespace().next().and_then(|v| v.parse().ok()))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// pid -> ppid for every process, then walk descendants of `me`.
|
||||
let mut parent: HashMap<u32, u32> = HashMap::new();
|
||||
if let Ok(rd) = std::fs::read_dir("/proc") {
|
||||
for e in rd.flatten() {
|
||||
if let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) {
|
||||
if let Some(st) = read_stat(pid) {
|
||||
parent.insert(pid, st.ppid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut owned: Vec<u32> = vec![me];
|
||||
let mut i = 0;
|
||||
while i < owned.len() {
|
||||
let cur = owned[i];
|
||||
for (pid, ppid) in &parent {
|
||||
if *ppid == cur && !owned.contains(pid) {
|
||||
owned.push(*pid);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut samples = G_CPU_SAMPLES.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for pid in owned {
|
||||
let Some(st) = read_stat(pid) else { continue };
|
||||
let name = read_cmdline_name(pid);
|
||||
let cpu = match samples.get(&pid) {
|
||||
Some((prev_ticks, prev_t)) => {
|
||||
let dt = now.duration_since(*prev_t).as_secs_f64();
|
||||
if dt > 0.05 {
|
||||
(st.ticks.saturating_sub(*prev_ticks) as f64 / clk_tck) / dt * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
// Only advance the sample when enough time has passed, so rapid
|
||||
// consecutive polls don't produce noisy deltas.
|
||||
match samples.get(&pid) {
|
||||
Some((_, prev_t)) if now.duration_since(*prev_t).as_secs_f64() <= 0.05 => {}
|
||||
_ => {
|
||||
samples.insert(pid, (st.ticks, now));
|
||||
}
|
||||
}
|
||||
seen.push(pid);
|
||||
let (rss, pss, swap) = read_mem(pid);
|
||||
let ownership = if pid == me { "browser" } else { classify(&name) };
|
||||
out.push(ProcInfo {
|
||||
pid,
|
||||
url: url.to_string(),
|
||||
title: title.to_string(),
|
||||
memory_mb: 0.0,
|
||||
cpu_percent: 0.0,
|
||||
name,
|
||||
cpu_percent: (cpu * 10.0).round() / 10.0,
|
||||
rss_kb: rss,
|
||||
pss_kb: pss,
|
||||
swap_kb: swap,
|
||||
threads: st.threads,
|
||||
uptime_sec: (sys_uptime - st.start_ticks as f64 / clk_tck).max(0.0),
|
||||
state: st.state,
|
||||
ownership: ownership.to_string(),
|
||||
service_state: None,
|
||||
hosted_tabs: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a web process.
|
||||
pub fn process_info_remove(pid: u32) {
|
||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
||||
state.web_processes.retain(|p| p.pid != pid);
|
||||
}
|
||||
samples.retain(|pid, _| seen.contains(pid));
|
||||
out
|
||||
}
|
||||
|
||||
+114
@@ -32,6 +32,103 @@ pub fn search_is_url(input: &str) -> bool {
|
||||
|| input.contains('.')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NPUB: &str = "npub1crpldvy49ef8z34wlacwujnfudy4nd7k96aqdx5wgn6ckztz7z8q9t59ud";
|
||||
|
||||
#[test]
|
||||
fn fips_adds_mesh_suffix() {
|
||||
assert_eq!(
|
||||
normalize_fips_url(&format!("fips://{NPUB}/")).unwrap(),
|
||||
format!("http://{NPUB}.fips/")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_fips_url(&format!("fips://{NPUB}")).unwrap(),
|
||||
format!("http://{NPUB}.fips")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fips_keeps_port_path_query_fragment() {
|
||||
assert_eq!(
|
||||
normalize_fips_url("fips://node:8080/a/b?x=1#frag").unwrap(),
|
||||
"http://node.fips:8080/a/b?x=1#frag"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_fips_url("fips://node?x=1").unwrap(),
|
||||
"http://node.fips?x=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fips_does_not_double_suffix() {
|
||||
assert_eq!(
|
||||
normalize_fips_url("fips://node.fips/p").unwrap(),
|
||||
"http://node.fips/p"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_fips_url("fips://node.fips:81/p").unwrap(),
|
||||
"http://node.fips:81/p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fips_rejects_non_fips_and_empty() {
|
||||
assert!(normalize_fips_url("http://x/").is_none());
|
||||
assert!(normalize_fips_url("fips://").is_none());
|
||||
assert!(normalize_fips_url("fips:///path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_url_handles_fips_forms() {
|
||||
assert_eq!(normalize_url("fips://n/x"), "http://n.fips/x");
|
||||
// Bare mesh names default to http (no TLS inside the mesh).
|
||||
assert_eq!(normalize_url("n.fips/x"), "http://n.fips/x");
|
||||
assert_eq!(normalize_url("n.fips:8080"), "http://n.fips:8080");
|
||||
// Ordinary hosts are unchanged.
|
||||
assert_eq!(normalize_url("example.com"), "https://example.com");
|
||||
assert_eq!(normalize_url("https://a.b/"), "https://a.b/");
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `fips://host[:port]/path?query#fragment` into a plain HTTP URL,
|
||||
/// inserting the mesh DNS suffix after the authority's host component.
|
||||
/// `fips://<npub>/x` becomes `http://<npub>.fips/x`. Returns `None` if the
|
||||
/// input is not a `fips://` URL. Port of `normalize_fips_url()` from
|
||||
/// tab_manager.c:262.
|
||||
///
|
||||
/// `fips://` is a shorthand rather than a WebKit URI scheme: the mesh
|
||||
/// resolves `*.fips` names through the FIPS daemon's DNS listener and
|
||||
/// routes traffic over the `fips0` TUN device, so no proxy is needed.
|
||||
pub fn normalize_fips_url(input: &str) -> Option<String> {
|
||||
let rest = input.trim().strip_prefix("fips://")?;
|
||||
let split = rest
|
||||
.find(|c| c == '/' || c == '?' || c == '#')
|
||||
.unwrap_or(rest.len());
|
||||
let (authority, suffix) = rest.split_at(split);
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (host, port) = match authority.rsplit_once(':') {
|
||||
// Not an IPv6 literal: a trailing ":digits" is a port.
|
||||
Some((h, p)) if !h.contains(':') && p.chars().all(|c| c.is_ascii_digit()) => {
|
||||
(h, Some(p))
|
||||
}
|
||||
_ => (authority, None),
|
||||
};
|
||||
let host = if host.ends_with(".fips") {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("{}.fips", host)
|
||||
};
|
||||
Some(match port {
|
||||
Some(p) if !p.is_empty() => format!("http://{}:{}{}", host, p, suffix),
|
||||
_ => format!("http://{}{}", host, suffix),
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalize a URL string: prepend `https://` if it has no scheme.
|
||||
/// Returns the normalized URL.
|
||||
pub fn normalize_url(input: &str) -> String {
|
||||
@@ -39,6 +136,23 @@ pub fn normalize_url(input: &str) -> String {
|
||||
if trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed.starts_with("fips://") {
|
||||
if let Some(url) = normalize_fips_url(trimmed) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
// Bare `<name>.fips` hosts are mesh names: they are served over plain
|
||||
// HTTP inside the mesh, so default to http:// rather than https://.
|
||||
if !trimmed.contains("://") {
|
||||
let host_end = trimmed
|
||||
.find(|c| c == '/' || c == '?' || c == '#')
|
||||
.unwrap_or(trimmed.len());
|
||||
let authority = &trimmed[..host_end];
|
||||
let host = authority.rsplit_once(':').map(|(h, _)| h).unwrap_or(authority);
|
||||
if host.ends_with(".fips") {
|
||||
return format!("http://{}", trimmed);
|
||||
}
|
||||
}
|
||||
// If it already has a scheme, return as-is.
|
||||
// Note: nostr: (NIP-21) is a valid scheme without //
|
||||
if trimmed.contains("://") || trimmed.starts_with("about:") || trimmed.starts_with("nostr:") {
|
||||
|
||||
@@ -50,6 +50,26 @@ pub struct BrowserSettings {
|
||||
/// Enable the FIPS mesh service. Mirrors the C `fips_enabled`
|
||||
/// (default TRUE).
|
||||
pub fips_enabled: bool,
|
||||
/// Tor service mode: "auto" (attach, else manage), "attach" or "manage".
|
||||
pub tor_mode: String,
|
||||
/// Tor binary used in managed mode.
|
||||
pub tor_binary_path: String,
|
||||
/// Explicit SOCKS endpoint to attach to (e.g. "127.0.0.1:9050" or
|
||||
/// "unix:/run/tor/socks"). Empty = auto-discover.
|
||||
pub tor_attach_socks: String,
|
||||
/// Explicit control endpoint to attach to (e.g. "127.0.0.1:9051" or
|
||||
/// "unix:/run/tor/control"). Empty = auto-discover.
|
||||
pub tor_attach_control: String,
|
||||
/// Data directory for a managed Tor.
|
||||
pub tor_data_dir: String,
|
||||
/// FIPS service mode: "auto", "attach" or "manage".
|
||||
pub fips_mode: String,
|
||||
/// FIPS binary used in managed mode.
|
||||
pub fips_binary_path: String,
|
||||
/// Explicit FIPS control socket. Empty = auto-discover.
|
||||
pub fips_control_socket: String,
|
||||
/// Config directory for a managed FIPS node.
|
||||
pub fips_config_dir: String,
|
||||
}
|
||||
|
||||
impl Default for BrowserSettings {
|
||||
@@ -89,6 +109,15 @@ impl Default for BrowserSettings {
|
||||
perf_probe_enabled: false,
|
||||
tor_enabled: true,
|
||||
fips_enabled: true,
|
||||
tor_mode: "auto".to_string(),
|
||||
tor_binary_path: "tor".to_string(),
|
||||
tor_attach_socks: String::new(),
|
||||
tor_attach_control: String::new(),
|
||||
tor_data_dir: "~/.sovereign_browser/tor".to_string(),
|
||||
fips_mode: "auto".to_string(),
|
||||
fips_binary_path: "fips".to_string(),
|
||||
fips_control_socket: String::new(),
|
||||
fips_config_dir: "~/.sovereign_browser/fips".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +182,34 @@ pub fn settings_update(json: &serde_json::Value) {
|
||||
if let Some(v) = json.get("fips_enabled").and_then(|v| v.as_bool()) {
|
||||
settings.fips_enabled = v;
|
||||
}
|
||||
let s = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|v| v.to_string());
|
||||
if let Some(v) = s("tor_mode") {
|
||||
settings.tor_mode = v;
|
||||
}
|
||||
if let Some(v) = s("tor_binary_path") {
|
||||
settings.tor_binary_path = v;
|
||||
}
|
||||
if let Some(v) = s("tor_attach_socks") {
|
||||
settings.tor_attach_socks = v;
|
||||
}
|
||||
if let Some(v) = s("tor_attach_control") {
|
||||
settings.tor_attach_control = v;
|
||||
}
|
||||
if let Some(v) = s("tor_data_dir") {
|
||||
settings.tor_data_dir = v;
|
||||
}
|
||||
if let Some(v) = s("fips_mode") {
|
||||
settings.fips_mode = v;
|
||||
}
|
||||
if let Some(v) = s("fips_binary_path") {
|
||||
settings.fips_binary_path = v;
|
||||
}
|
||||
if let Some(v) = s("fips_control_socket") {
|
||||
settings.fips_control_socket = v;
|
||||
}
|
||||
if let Some(v) = s("fips_config_dir") {
|
||||
settings.fips_config_dir = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the Tor / FIPS enabled preference and persist it.
|
||||
@@ -198,6 +255,15 @@ pub fn settings_save() {
|
||||
"enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout,
|
||||
"tor_enabled": settings.tor_enabled,
|
||||
"fips_enabled": settings.fips_enabled,
|
||||
"tor_mode": settings.tor_mode,
|
||||
"tor_binary_path": settings.tor_binary_path,
|
||||
"tor_attach_socks": settings.tor_attach_socks,
|
||||
"tor_attach_control": settings.tor_attach_control,
|
||||
"tor_data_dir": settings.tor_data_dir,
|
||||
"fips_mode": settings.fips_mode,
|
||||
"fips_binary_path": settings.fips_binary_path,
|
||||
"fips_control_socket": settings.fips_control_socket,
|
||||
"fips_config_dir": settings.fips_config_dir,
|
||||
});
|
||||
drop(settings);
|
||||
|
||||
|
||||
@@ -793,6 +793,21 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── fips:// → http://<host>.fips normalization ─────
|
||||
// fips:// is a shorthand, not a registered WebKit
|
||||
// scheme; this catches page links in addition to
|
||||
// URL-bar normalization. Mirrors tab_manager.c:1063.
|
||||
if uri_str.starts_with("fips://") {
|
||||
if let Some(normalized) = crate::search::normalize_fips_url(&uri_str) {
|
||||
decision.ignore();
|
||||
let wv = wv_for_onion.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
wv.load_uri(&normalized);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── .onion HTTP(S) → tor:// rewriting ──────────────
|
||||
// Detect .onion hosts in http:// or https:// URLs and
|
||||
// rewrite to tor:// so the request goes through Tor's
|
||||
|
||||
+251
-114
@@ -2,23 +2,32 @@
|
||||
//!
|
||||
//! Port of `tor_control.c` / `tor_control.h` from the C project.
|
||||
//! Implements a synchronous, short-timeout Tor control-protocol client
|
||||
//! supporting both TCP (host:port) and Unix socket endpoints.
|
||||
//! supporting both TCP (`host:port`) and Unix socket (`unix:/path`)
|
||||
//! endpoints, with cookie, password or null authentication.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const TOR_RESPONSE_MAX: usize = 8192;
|
||||
const TOR_TIMEOUT_MS: u64 = 1500;
|
||||
const TOR_CONNECT_TIMEOUT_MS: u64 = 500;
|
||||
/// Upper bound on a single control reply (guards against a runaway peer).
|
||||
const TOR_REPLY_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// Tor control state.
|
||||
static G_TOR_CONTROL: Lazy<Mutex<TorControlState>> = Lazy::new(|| Mutex::new(TorControlState::default()));
|
||||
static G_TOR_CONTROL: Lazy<Mutex<TorControlState>> =
|
||||
Lazy::new(|| Mutex::new(TorControlState::default()));
|
||||
|
||||
struct TorControlState {
|
||||
connected: bool,
|
||||
control_host: String,
|
||||
control_port: u16,
|
||||
/// `host:port` or `unix:/path`.
|
||||
endpoint: String,
|
||||
password: String,
|
||||
cookie_path: String,
|
||||
}
|
||||
@@ -27,145 +36,273 @@ impl Default for TorControlState {
|
||||
fn default() -> Self {
|
||||
TorControlState {
|
||||
connected: false,
|
||||
control_host: "127.0.0.1".to_string(),
|
||||
control_port: 9051,
|
||||
endpoint: "127.0.0.1:9051".to_string(),
|
||||
password: String::new(),
|
||||
cookie_path: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transport ───────────────────────────────────────────────────────
|
||||
|
||||
enum Stream {
|
||||
Tcp(TcpStream),
|
||||
Unix(UnixStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn connect(endpoint: &str) -> io::Result<Stream> {
|
||||
let timeout = Duration::from_millis(TOR_CONNECT_TIMEOUT_MS);
|
||||
let stream = if let Some(path) = endpoint.strip_prefix("unix:") {
|
||||
Stream::Unix(UnixStream::connect(path)?)
|
||||
} else {
|
||||
let addr = endpoint
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "bad Tor endpoint"))?;
|
||||
Stream::Tcp(TcpStream::connect_timeout(&addr, timeout)?)
|
||||
};
|
||||
stream.set_timeouts(Duration::from_millis(TOR_TIMEOUT_MS))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn set_timeouts(&self, d: Duration) -> io::Result<()> {
|
||||
match self {
|
||||
Stream::Tcp(s) => {
|
||||
s.set_read_timeout(Some(d))?;
|
||||
s.set_write_timeout(Some(d))
|
||||
}
|
||||
Stream::Unix(s) => {
|
||||
s.set_read_timeout(Some(d))?;
|
||||
s.set_write_timeout(Some(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_clone(&self) -> io::Result<Stream> {
|
||||
Ok(match self {
|
||||
Stream::Tcp(s) => Stream::Tcp(s.try_clone()?),
|
||||
Stream::Unix(s) => Stream::Unix(s.try_clone()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.read(buf),
|
||||
Stream::Unix(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.write(buf),
|
||||
Stream::Unix(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
Stream::Tcp(s) => s.flush(),
|
||||
Stream::Unix(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An authenticated control-protocol session.
|
||||
struct Session {
|
||||
reader: BufReader<Stream>,
|
||||
writer: Stream,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn open(endpoint: &str, cookie_path: &str, password: &str) -> Result<Session> {
|
||||
let stream = Stream::connect(endpoint)
|
||||
.map_err(|e| format!("Tor control connect failed ({}): {}", endpoint, e))?;
|
||||
let writer = stream.try_clone()?;
|
||||
let mut session = Session { reader: BufReader::new(stream), writer };
|
||||
|
||||
let auth = if !cookie_path.is_empty() {
|
||||
let cookie = std::fs::read(cookie_path)
|
||||
.map_err(|e| format!("cannot read Tor cookie {}: {}", cookie_path, e))?;
|
||||
let hex: String = cookie.iter().map(|b| format!("{:02X}", b)).collect();
|
||||
format!("AUTHENTICATE {}", hex)
|
||||
} else if !password.is_empty() {
|
||||
format!("AUTHENTICATE \"{}\"", password.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
} else {
|
||||
"AUTHENTICATE".to_string()
|
||||
};
|
||||
session
|
||||
.command(&auth)
|
||||
.map_err(|e| format!("Tor authentication failed: {}", e))?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Send one command and read its complete reply.
|
||||
fn command(&mut self, command: &str) -> Result<String> {
|
||||
self.writer.write_all(format!("{}\r\n", command).as_bytes())?;
|
||||
self.writer.flush()?;
|
||||
self.read_reply()
|
||||
}
|
||||
|
||||
/// Read a full reply. Mid lines look like `250-...`, data blocks like
|
||||
/// `250+...` (terminated by a lone `.`), and the final line `250 ...`.
|
||||
/// Returns the reply text on a 2xx status, an error otherwise.
|
||||
fn read_reply(&mut self) -> Result<String> {
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = self.reader.read_line(&mut line).map_err(|e| {
|
||||
if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut {
|
||||
"Tor control response timed out".to_string()
|
||||
} else {
|
||||
format!("Tor control read failed: {}", e)
|
||||
}
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err("Tor closed control connection".into());
|
||||
}
|
||||
text.push_str(&line);
|
||||
if text.len() > TOR_REPLY_MAX_BYTES {
|
||||
return Err("Tor control reply too large".into());
|
||||
}
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
match trimmed.as_bytes()[3] {
|
||||
b'+' => {
|
||||
// Data block: consume until a line containing only ".".
|
||||
loop {
|
||||
let mut data = String::new();
|
||||
if self.reader.read_line(&mut data)? == 0 {
|
||||
return Err("Tor closed control connection".into());
|
||||
}
|
||||
text.push_str(&data);
|
||||
if data.trim_end() == "." {
|
||||
break;
|
||||
}
|
||||
if text.len() > TOR_REPLY_MAX_BYTES {
|
||||
return Err("Tor control reply too large".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
b' ' => {
|
||||
return if trimmed.starts_with('2') {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(format!("Tor control error: {}", trimmed).into())
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Endpoint-explicit helpers (used by net_services) ────────────────
|
||||
|
||||
/// True if something accepts connections on the control endpoint.
|
||||
pub fn tor_control_endpoint_available(endpoint: &str) -> bool {
|
||||
Stream::connect(endpoint).is_ok()
|
||||
}
|
||||
|
||||
/// Run one command on a fresh authenticated connection.
|
||||
pub fn tor_control_command_on(
|
||||
endpoint: &str,
|
||||
cookie_path: &str,
|
||||
password: &str,
|
||||
command: &str,
|
||||
) -> Result<String> {
|
||||
Session::open(endpoint, cookie_path, password)?.command(command)
|
||||
}
|
||||
|
||||
/// Parse a `status/bootstrap-phase` reply into `(progress, summary)`.
|
||||
fn parse_bootstrap(reply: &str) -> (i32, String) {
|
||||
let progress = reply
|
||||
.find("PROGRESS=")
|
||||
.and_then(|i| reply[i + 9..].split(|c: char| !c.is_ascii_digit()).next())
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let summary = reply
|
||||
.find("SUMMARY=\"")
|
||||
.and_then(|i| reply[i + 9..].split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(progress, summary)
|
||||
}
|
||||
|
||||
/// Bootstrap progress (0-100) and summary of the Tor at `endpoint`.
|
||||
pub fn tor_control_bootstrap_on(endpoint: &str, cookie_path: &str) -> Result<(i32, String)> {
|
||||
let reply = tor_control_command_on(endpoint, cookie_path, "", "GETINFO status/bootstrap-phase")?;
|
||||
Ok(parse_bootstrap(&reply))
|
||||
}
|
||||
|
||||
// ── Global-state API ────────────────────────────────────────────────
|
||||
|
||||
/// Initialize Tor control.
|
||||
pub fn tor_control_init() {
|
||||
G_TOR_CONTROL.lock().unwrap().connected = false;
|
||||
}
|
||||
|
||||
/// Point the global control client at a Tor control endpoint
|
||||
/// (`host:port` or `unix:/path`) and its cookie file (may be empty).
|
||||
pub fn tor_control_configure(endpoint: &str, cookie_path: &str) {
|
||||
let mut state = G_TOR_CONTROL.lock().unwrap();
|
||||
state.endpoint = endpoint.to_string();
|
||||
state.cookie_path = cookie_path.to_string();
|
||||
state.connected = false;
|
||||
}
|
||||
|
||||
/// Connect to the Tor control port (TCP host:port).
|
||||
pub fn tor_control_connect() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut state = G_TOR_CONTROL.lock().unwrap();
|
||||
let host = state.control_host.clone();
|
||||
let port = state.control_port;
|
||||
let cookie = state.cookie_path.clone();
|
||||
let password = state.password.clone();
|
||||
|
||||
// Connect to the control port.
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = TcpStream::connect(&addr)?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
|
||||
|
||||
// Authenticate.
|
||||
let auth_cmd = if !cookie.is_empty() {
|
||||
// Read the cookie file and hex-encode it.
|
||||
let cookie_bytes = std::fs::read(&cookie)?;
|
||||
let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect();
|
||||
format!("AUTHENTICATE {}\r\n", hex)
|
||||
} else if !password.is_empty() {
|
||||
format!("AUTHENTICATE \"{}\"\r\n", password)
|
||||
} else {
|
||||
"AUTHENTICATE\r\n".to_string()
|
||||
/// Verify that the configured control port is reachable and accepts our
|
||||
/// credentials.
|
||||
pub fn tor_control_connect() -> Result<()> {
|
||||
let (endpoint, cookie, password) = {
|
||||
let s = G_TOR_CONTROL.lock().unwrap();
|
||||
(s.endpoint.clone(), s.cookie_path.clone(), s.password.clone())
|
||||
};
|
||||
|
||||
stream.write_all(auth_cmd.as_bytes())?;
|
||||
let response = read_response(&mut stream)?;
|
||||
if !response.starts_with("250") {
|
||||
return Err(format!("Tor authentication failed: {}", response).into());
|
||||
}
|
||||
|
||||
state.connected = true;
|
||||
println!("[tor] Connected to control port {}:{}", host, port);
|
||||
Session::open(&endpoint, &cookie, &password)?;
|
||||
G_TOR_CONTROL.lock().unwrap().connected = true;
|
||||
println!("[tor] Connected to control port {}", endpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from Tor control port.
|
||||
pub fn tor_control_disconnect() {
|
||||
let mut state = G_TOR_CONTROL.lock().unwrap();
|
||||
state.connected = false;
|
||||
G_TOR_CONTROL.lock().unwrap().connected = false;
|
||||
println!("[tor] Disconnected from control port");
|
||||
}
|
||||
|
||||
/// Check if connected to Tor control.
|
||||
#[allow(dead_code)]
|
||||
pub fn tor_control_is_connected() -> bool {
|
||||
let state = G_TOR_CONTROL.lock().unwrap();
|
||||
state.connected
|
||||
G_TOR_CONTROL.lock().unwrap().connected
|
||||
}
|
||||
|
||||
/// Send a command to the Tor control port and return the response.
|
||||
pub fn tor_control_send_command(command: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let state = G_TOR_CONTROL.lock().unwrap();
|
||||
if !state.connected {
|
||||
/// Send a command to the Tor control port and return the reply. Each
|
||||
/// command uses a short-lived authenticated connection.
|
||||
pub fn tor_control_send_command(command: &str) -> Result<String> {
|
||||
let (endpoint, cookie, password, connected) = {
|
||||
let s = G_TOR_CONTROL.lock().unwrap();
|
||||
(s.endpoint.clone(), s.cookie_path.clone(), s.password.clone(), s.connected)
|
||||
};
|
||||
if !connected {
|
||||
return Err("Not connected to Tor control port".into());
|
||||
}
|
||||
let host = state.control_host.clone();
|
||||
let port = state.control_port;
|
||||
|
||||
// Open a fresh connection for the command (synchronous, short-lived).
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = TcpStream::connect(&addr)?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
|
||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?;
|
||||
|
||||
// Authenticate first (needed for most commands).
|
||||
let auth_cmd = if !state.cookie_path.is_empty() {
|
||||
let cookie_bytes = std::fs::read(&state.cookie_path)?;
|
||||
let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect();
|
||||
format!("AUTHENTICATE {}\r\n", hex)
|
||||
} else if !state.password.is_empty() {
|
||||
format!("AUTHENTICATE \"{}\"\r\n", state.password)
|
||||
} else {
|
||||
"AUTHENTICATE\r\n".to_string()
|
||||
};
|
||||
stream.write_all(auth_cmd.as_bytes())?;
|
||||
let _ = read_response(&mut stream)?;
|
||||
|
||||
// Send the actual command.
|
||||
let wire = format!("{}\r\n", command);
|
||||
stream.write_all(wire.as_bytes())?;
|
||||
let response = read_response(&mut stream)?;
|
||||
Ok(response)
|
||||
tor_control_command_on(&endpoint, &cookie, &password, command)
|
||||
}
|
||||
|
||||
/// Request a new Tor identity (SIGNAL NEWNYM).
|
||||
pub fn tor_control_new_identity() -> Result<String, Box<dyn std::error::Error>> {
|
||||
#[allow(dead_code)]
|
||||
pub fn tor_control_new_identity() -> Result<String> {
|
||||
tor_control_send_command("SIGNAL NEWNYM")
|
||||
}
|
||||
|
||||
/// Get the Tor bootstrap progress.
|
||||
pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box<dyn std::error::Error>> {
|
||||
let response = tor_control_send_command("GETINFO status/bootstrap-phase")?;
|
||||
// Parse "PROGRESS=NN" from the response.
|
||||
let progress = response
|
||||
.find("PROGRESS=")
|
||||
.and_then(|i| response[i + 9..].split(|c: char| !c.is_ascii_digit()).next())
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
Ok((progress, response))
|
||||
}
|
||||
|
||||
/// Read a full Tor control response (until a line with "250 " or an error code).
|
||||
fn read_response(stream: &mut dyn Read) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut buf = [0u8; TOR_RESPONSE_MAX];
|
||||
let mut used = 0usize;
|
||||
while used < TOR_RESPONSE_MAX {
|
||||
let n = stream.read(&mut buf[used..])?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
used += n;
|
||||
let text = String::from_utf8_lossy(&buf[..used]);
|
||||
// Check for a final line: "250 " (success) or "5xx " (error).
|
||||
for line in text.lines() {
|
||||
if line.len() >= 4 && line.as_bytes()[3] == b' ' {
|
||||
let code = &line[..3];
|
||||
if code == "250" {
|
||||
return Ok(text.to_string());
|
||||
}
|
||||
if code.starts_with('5') || code.starts_with('4') {
|
||||
return Err(format!("Tor control error: {}", line).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&buf[..used]).to_string())
|
||||
#[allow(dead_code)]
|
||||
pub fn tor_control_get_bootstrap() -> Result<(i32, String)> {
|
||||
let reply = tor_control_send_command("GETINFO status/bootstrap-phase")?;
|
||||
Ok(parse_bootstrap(&reply))
|
||||
}
|
||||
|
||||
+24
-8
@@ -14,8 +14,8 @@ use glib::Bytes;
|
||||
use gio::MemoryInputStream;
|
||||
use webkit2gtk::*;
|
||||
|
||||
/// Default Tor SOCKS endpoint (socks5h = remote DNS resolution through Tor).
|
||||
const DEFAULT_SOCKS_ENDPOINT: &str = "socks5h://127.0.0.1:9050";
|
||||
/// How long a `tor://` request waits for Tor to become ready.
|
||||
const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Maximum response size (16 MB, matching the C version).
|
||||
const TOR_FETCH_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
@@ -49,17 +49,16 @@ fn handle_tor_scheme(request: &URISchemeRequest) {
|
||||
}
|
||||
};
|
||||
|
||||
// Get the Tor SOCKS endpoint from net_services, falling back to default.
|
||||
let socks_endpoint = crate::net_services::net_services_get_tor_socks_endpoint()
|
||||
.unwrap_or_else(|| DEFAULT_SOCKS_ENDPOINT.to_string());
|
||||
|
||||
// Shared result buffer: worker thread writes, main thread reads.
|
||||
let result: Arc<Mutex<Option<Result<(Vec<u8>, Option<String>), String>>>> = Arc::new(Mutex::new(None));
|
||||
let result_worker = result.clone();
|
||||
|
||||
// Spawn a blocking worker thread (reqwest doesn't need async here).
|
||||
// The worker also waits for Tor to finish bootstrapping (starting it
|
||||
// on demand), so the GTK main thread never blocks.
|
||||
std::thread::spawn(move || {
|
||||
let r = fetch_via_socks_blocking(&target_url, &socks_endpoint);
|
||||
let r = crate::net_services::net_services_tor_socks_for_request(TOR_READY_WAIT)
|
||||
.and_then(|socks| fetch_via_socks_blocking(&target_url, &socks));
|
||||
*result_worker.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
@@ -169,7 +168,7 @@ fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec<u8>, Option<S
|
||||
|
||||
let response: reqwest::blocking::Response = match client.get(url).send() {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(e) => return Err(error_chain(&e)),
|
||||
};
|
||||
|
||||
// Check content-length header before reading the body.
|
||||
@@ -196,6 +195,23 @@ fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec<u8>, Option<S
|
||||
Ok((bytes, content_type))
|
||||
}
|
||||
|
||||
/// Render an error with its full `source()` chain. reqwest's own
|
||||
/// `Display` stops at "error sending request", hiding the SOCKS failure
|
||||
/// (e.g. "host unreachable", "TTL expired") that explains it.
|
||||
fn error_chain(e: &dyn std::error::Error) -> String {
|
||||
let mut msg = e.to_string();
|
||||
let mut src = e.source();
|
||||
while let Some(s) = src {
|
||||
let part = s.to_string();
|
||||
if !msg.contains(&part) {
|
||||
msg.push_str(": ");
|
||||
msg.push_str(&part);
|
||||
}
|
||||
src = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Respond with an error HTML page.
|
||||
fn respond_error(request: &URISchemeRequest, title: &str, message: &str) {
|
||||
let html = format!(
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
//! Version information for sovereign_browser
|
||||
|
||||
/// The current version of sovereign_browser (with leading 'v').
|
||||
pub const VERSION: &str = "v0.0.12";
|
||||
pub const VERSION: &str = "v0.0.13";
|
||||
|
||||
/// Major version number.
|
||||
pub const VERSION_MAJOR: u32 = 0;
|
||||
@@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0;
|
||||
pub const VERSION_MINOR: u32 = 0;
|
||||
|
||||
/// Patch version number.
|
||||
pub const VERSION_PATCH: u32 = 12;
|
||||
pub const VERSION_PATCH: u32 = 13;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<th data-sort="cpu_percent">CPU%</th>
|
||||
<th data-sort="rss_kb">RSS</th>
|
||||
<th data-sort="pss_kb">PSS</th>
|
||||
<th data-sort="swap_kb">Swap</th>
|
||||
<th data-sort="threads">Threads</th>
|
||||
<th data-sort="uptime_sec">Uptime</th>
|
||||
<th data-sort="state">State</th>
|
||||
|
||||
+3
-2
@@ -153,6 +153,7 @@ function renderProcTable() {
|
||||
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
|
||||
'<td>' + fmtKb(p.rss_kb) + '</td>' +
|
||||
'<td>' + fmtKb(p.pss_kb) + '</td>' +
|
||||
'<td>' + fmtKb(p.swap_kb) + '</td>' +
|
||||
'<td>' + (p.threads || 0) + '</td>' +
|
||||
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
|
||||
'<td>' + esc(p.state) + '</td>' +
|
||||
@@ -173,7 +174,7 @@ function renderProcTable() {
|
||||
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
|
||||
var sub = document.createElement('tr');
|
||||
sub.className = 'hosted-tabs-row';
|
||||
var html = '<td colspan="10">';
|
||||
var html = '<td colspan="11">';
|
||||
p.hosted_tabs.forEach(function (t) {
|
||||
html += '<span class="ht-entry"><span class="ht-idx">#' +
|
||||
t.index + '</span>' + esc(t.title || '(untitled)') +
|
||||
@@ -186,7 +187,7 @@ function renderProcTable() {
|
||||
});
|
||||
|
||||
if (sorted.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
|
||||
body.innerHTML = '<tr><td colspan="11" class="empty">No processes.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user