v0.0.7 - Disable perf probe by default (fix 322% WebProcess CPU), gate behind setting, top-frame only, quiet console logging

This commit is contained in:
Laan Tungir
2026-09-20 08:34:29 -04:00
parent d131762ce8
commit 602ee2f13b
12 changed files with 264 additions and 52 deletions
Generated
+1 -1
View File
@@ -3056,7 +3056,7 @@ dependencies = [
[[package]]
name = "sovereign_browser"
version = "0.0.5"
version = "0.0.7"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -6,7 +6,7 @@ members = [
[package]
name = "sovereign_browser"
version = "0.0.6"
version = "0.0.7"
edition = "2021"
license = "MIT"
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
+1 -1
View File
@@ -1 +1 @@
0.0.6
0.0.7
+25 -6
View File
@@ -4,10 +4,15 @@ set -e
# run.sh - Build (only if needed) and run the sovereign_browser binary.
#
# Usage:
# ./run.sh [args...] # build if stale, then run with args
# ./run.sh [args...] # build if stale (debug), then run with args
# ./run.sh --release # build/run the optimized release binary instead
# ./run.sh --build # force a rebuild, then run
# ./run.sh --no-build # run without building (fails if missing)
#
# The default profile is DEBUG: it rebuilds in a few seconds, which is what
# you want while iterating. Pass --release for the optimized binary (much
# slower to build, faster at runtime).
#
# The binary is rebuilt only when it is missing or when any source file
# (src/**, Cargo.toml, Cargo.lock, www/**) is newer than the binary.
@@ -29,15 +34,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
BIN_NAME="sovereign_browser"
BIN_PATH="target/release/${BIN_NAME}"
# --- Args ---------------------------------------------------------------
FORCE_BUILD=false
NO_BUILD=false
RELEASE=false
RUN_ARGS=()
for arg in "$@"; do
case "$arg" in
--release)
RELEASE=true
;;
--build)
FORCE_BUILD=true
;;
@@ -50,6 +58,17 @@ for arg in "$@"; do
esac
done
# --- Profile ------------------------------------------------------------
# Debug is the default; --release selects the optimized profile.
if [[ "$RELEASE" == true ]]; then
PROFILE_DIR="release"
CARGO_PROFILE_ARGS=(--release)
else
PROFILE_DIR="debug"
CARGO_PROFILE_ARGS=()
fi
BIN_PATH="target/${PROFILE_DIR}/${BIN_NAME}"
# --- Determine whether a build is needed --------------------------------
needs_build() {
# No binary yet -> must build.
@@ -70,12 +89,12 @@ if [[ "$NO_BUILD" == true ]]; then
fi
print_status "Skipping build (--no-build)."
elif [[ "$FORCE_BUILD" == true ]]; then
print_status "Forcing rebuild of ${BIN_NAME} (release)..."
cargo build --release
print_status "Forcing rebuild of ${BIN_NAME} (${PROFILE_DIR})..."
cargo build "${CARGO_PROFILE_ARGS[@]}"
print_success "Build complete."
elif needs_build; then
print_status "Sources changed or binary missing; building ${BIN_NAME} (release)..."
cargo build --release
print_status "Sources changed or binary missing; building ${BIN_NAME} (${PROFILE_DIR})..."
cargo build "${CARGO_PROFILE_ARGS[@]}"
print_success "Build complete."
else
print_status "Binary is up to date; skipping build."
+171 -23
View File
@@ -62,8 +62,12 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
let route = path.split('?').next().unwrap_or(path);
// ── nostr API ───────────────────────────────────────────────────
if route.starts_with("nostr/") {
handle_nostr_api(request, &route[6..]);
// NOTE: pass `path` (which still includes the query string), not
// `route`. The JS shim encodes the request body as `?body=<json>`,
// so stripping the query here would make every body-bearing method
// (signEvent, nip04/nip44) fail with "Invalid request body".
if path.starts_with("nostr/") {
handle_nostr_api(request, &path[6..]);
return;
}
@@ -527,6 +531,16 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) {
let _ = db::db_kv_set("agent_server_enabled", new);
(new == "true", true)
}
"perf_probe_enabled" => {
// Toggle the per-tab performance probe. OFF by default; the
// probe patches hot JS APIs and runs a permanent rAF loop.
// Takes effect on the next tab (or reload).
let mut s = settings::settings_get();
s.perf_probe_enabled = !s.perf_probe_enabled;
settings::settings_update(&serde_json::json!({"perf_probe_enabled": s.perf_probe_enabled}));
settings::settings_save();
(s.perf_probe_enabled, false)
}
_ => {
respond_error_json(request, 400, "Unknown feature");
return;
@@ -687,6 +701,7 @@ fn handle_settings_config_json(request: &URISchemeRequest) {
"dev_extras": true,
"file_access": true,
"universal_access": true,
"perf_probe_enabled": s.perf_probe_enabled,
"search_engines": search_engines,
"shortcuts": shortcuts,
});
@@ -721,8 +736,17 @@ fn handle_nostr_api(request: &URISchemeRequest, method: &str) {
respond_error_json(request, 403, "Read-only mode");
return;
}
if let Some(ref signer) = state.signer {
match serde_json::from_str::<nostr_core::types::Event>(&event_json) {
let signer = match &state.signer {
Some(s) => s.clone(),
None => {
respond_error_json(request, 500, "No signer available");
return;
}
};
let pubkey_hex = state.pubkey_hex.clone();
drop(state);
match parse_unsigned_event(&event_json, &pubkey_hex) {
Ok(event) => {
match signer.sign_event(&event) {
Ok(signed) => {
@@ -732,10 +756,7 @@ fn handle_nostr_api(request: &URISchemeRequest, method: &str) {
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
Err(e) => respond_error_json(request, 400, &e.to_string()),
}
} else {
respond_error_json(request, 500, "No signer available");
Err(e) => respond_error_json(request, 400, &e),
}
}
"getRelays" => {
@@ -817,22 +838,31 @@ fn handle_nostr_api(request: &URISchemeRequest, method: &str) {
let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or("");
match signer.nip44_encrypt(&peer_pk, plaintext) {
Ok(ciphertext) => {
let json = serde_json::json!({ "result": ciphertext });
// NIP-44 ciphertext is raw bytes; the NIP-07 API
// (and the C bridge) represent it as a base64
// string. Serializing the Vec<u8> directly would
// emit a JSON array of numbers instead.
let b64 = nostr_core::util::base64_encode(&ciphertext);
let json = serde_json::json!({ "result": b64 });
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
}
Err(e) => respond_error_json(request, 500, &e.to_string()),
}
}
"nip44Decrypt" => {
let ciphertext_bytes: Vec<u8> = params.get("ciphertext").map(|v| {
if let Some(s) = v.as_str() {
s.as_bytes().to_vec()
} else if let Some(arr) = v.as_array() {
arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()
} else {
Vec::new()
// The JS shim sends the ciphertext as a base64 string
// (NIP-07 convention). The signer expects the raw binary
// payload, so base64-decode it. Passing the base64 text's
// ASCII bytes through unchanged corrupts the payload and
// makes the remote signer reject it with invalid_params.
let ciphertext_b64 = params.get("ciphertext").and_then(|v| v.as_str()).unwrap_or("");
let ciphertext_bytes = match nostr_core::util::base64_decode(ciphertext_b64) {
Ok(b) => b,
Err(e) => {
respond_error_json(request, 400, &format!("Invalid base64 ciphertext: {}", e));
return;
}
}).unwrap_or_default();
};
match signer.nip44_decrypt(&peer_pk, &ciphertext_bytes) {
Ok(plaintext) => {
let json = serde_json::json!({ "result": plaintext });
@@ -862,8 +892,14 @@ fn extract_query_param(query: &str, key: &str) -> Option<String> {
}
/// Simple URL percent-decoding.
///
/// Decodes into a byte buffer first, then interprets the result as UTF-8.
/// This is required for correctness: `encodeURIComponent` percent-encodes
/// each byte of a multi-byte UTF-8 sequence separately, so pushing each
/// decoded byte as a `char` (the previous behaviour) would corrupt any
/// non-ASCII content (e.g. emoji or accented characters in a post body).
fn url_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut bytes: Vec<u8> = Vec::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
@@ -871,18 +907,22 @@ fn url_decode(s: &str) -> String {
let h2 = chars.next();
if let (Some(a), Some(b)) = (h1, h2) {
if let Ok(byte) = u8::from_str_radix(&format!("{}{}", a, b), 16) {
result.push(byte as char);
bytes.push(byte);
continue;
}
}
result.push(c);
// Malformed escape — emit the literal '%' and the consumed chars.
bytes.push(b'%');
if let Some(a) = h1 { bytes.extend_from_slice(a.to_string().as_bytes()); }
if let Some(b) = h2 { bytes.extend_from_slice(b.to_string().as_bytes()); }
} else if c == '+' {
result.push(' ');
bytes.push(b' ');
} else {
result.push(c);
let mut buf = [0u8; 4];
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
}
result
String::from_utf8_lossy(&bytes).into_owned()
}
/// Parse a hex pubkey string into a PublicKey.
@@ -895,6 +935,114 @@ fn parse_pubkey_hex(hex_str: &str) -> Option<nostr_core::types::PublicKey> {
Some(nostr_core::types::PublicKey::from_bytes(arr))
}
/// Parse an unsigned event JSON body from the JS shim into a
/// `nostr_core::types::Event`, injecting the active identity's pubkey.
///
/// The JS shim sends an *unsigned* event — typically
/// `{kind, content, tags, created_at}` with no `pubkey`/`id`/`sig`.
/// `nostr_core::types::Event` requires `pubkey` (it is not an `Option`),
/// so deserializing the raw body fails with "missing field `pubkey`".
/// This fills the pubkey in from the signed-in user, mirroring the C
/// bridge. An explicit non-empty `pubkey` in the body is preserved.
fn parse_unsigned_event(
event_json: &str,
pubkey_hex: &str,
) -> Result<nostr_core::types::Event, String> {
let mut value: serde_json::Value =
serde_json::from_str(event_json).map_err(|e| format!("Invalid JSON: {}", e))?;
if let Some(obj) = value.as_object_mut() {
let needs_pubkey = obj
.get("pubkey")
.and_then(|v| v.as_str())
.map(|s| s.is_empty())
.unwrap_or(true);
if needs_pubkey {
obj.insert(
"pubkey".to_string(),
serde_json::Value::String(pubkey_hex.to_string()),
);
}
}
serde_json::from_value::<nostr_core::types::Event>(value).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const PK: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
#[test]
fn injects_pubkey_when_missing() {
// Exactly what the JS shim sends for a kind-1 post.
let body = r#"{"kind":1,"content":"Hello world","tags":[],"created_at":1700000000}"#;
let event = parse_unsigned_event(body, PK).expect("should parse");
assert_eq!(event.pubkey.to_hex(), PK);
assert_eq!(event.content, "Hello world");
assert_eq!(event.kind.as_u64(), 1);
assert_eq!(event.created_at, 1700000000);
}
#[test]
fn preserves_explicit_pubkey() {
let other = "0000000000000000000000000000000000000000000000000000000000000001";
let body = format!(
r#"{{"pubkey":"{}","kind":1,"content":"x","tags":[],"created_at":1}}"#,
other
);
let event = parse_unsigned_event(&body, PK).expect("should parse");
assert_eq!(event.pubkey.to_hex(), other);
}
#[test]
fn parses_tags_and_imeta() {
let body = r#"{"kind":1,"content":"hi","tags":[["imeta","url https://example.com/a.png"]],"created_at":5}"#;
let event = parse_unsigned_event(body, PK).expect("should parse");
assert_eq!(event.tags.len(), 1);
assert_eq!(event.tags[0].0[0], "imeta");
}
#[test]
fn rejects_invalid_json() {
assert!(parse_unsigned_event("not json", PK).is_err());
}
/// Round-trip the NIP-44 base64 encoding the bridge uses.
///
/// The JS shim sends the ciphertext as a base64 string; the bridge must
/// base64-decode it before handing raw bytes to the signer, and must
/// base64-encode the signer's raw output before returning it. This test
/// exercises that exact path with two real local signers.
#[test]
fn nip44_base64_round_trip() {
use nostr_signer::local::LocalSigner;
use nostr_signer::traits::NostrSigner;
let (sk_a, pk_a) = nostr_core::crypto::keys::generate_keypair();
let (sk_b, pk_b) = nostr_core::crypto::keys::generate_keypair();
let signer_a = LocalSigner::new(sk_a).expect("signer a");
let signer_b = LocalSigner::new(sk_b).expect("signer b");
let plaintext = "Hello, giftwrap!";
// Encrypt as A→B, then base64-encode (what the bridge returns).
let raw_ct = signer_a.nip44_encrypt(&pk_b, plaintext).expect("encrypt");
let b64_ct = nostr_core::util::base64_encode(&raw_ct);
// The base64 string must not be valid raw ciphertext bytes — this is
// exactly the bug: passing the ASCII of the base64 text to the signer
// fails. Decoding first must succeed.
let decoded = nostr_core::util::base64_decode(&b64_ct).expect("decode");
assert_eq!(decoded, raw_ct);
// Decrypt as B←A using the decoded raw bytes.
let decrypted = signer_b.nip44_decrypt(&pk_a, &decoded).expect("decrypt");
assert_eq!(String::from_utf8(decrypted).unwrap(), plaintext);
}
}
fn respond_json(request: &URISchemeRequest, json: &str) {
let body = json.to_string().into_bytes();
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
+6 -3
View File
@@ -188,19 +188,22 @@ pub fn nostr_inject_create_content_manager_with_probe(tab_index: i32) -> UserCon
manager.add_script(&script);
// Perf probe preamble (skips sovereign:// pages, sets tab index).
// Top frame only — mirrors the C version (perf_probe.c:67). Injecting
// into all frames would run a separate probe instance (with its own
// rAF loop and 1s sync XHR) in every iframe.
let preamble = UserScript::new(
&perf_probe_preamble(tab_index),
UserContentInjectedFrames::AllFrames,
UserContentInjectedFrames::TopFrame,
UserScriptInjectionTime::Start,
&[],
&[],
);
manager.add_script(&preamble);
// Perf probe body.
// Perf probe body (top frame only — see above).
let probe = UserScript::new(
PERF_PROBE_JS,
UserContentInjectedFrames::AllFrames,
UserContentInjectedFrames::TopFrame,
UserScriptInjectionTime::Start,
&[],
&[],
+19
View File
@@ -38,6 +38,12 @@ pub struct BrowserSettings {
pub enable_smooth_scrolling: bool,
pub enable_accelerated_2d_canvas: bool,
pub enable_write_console_messages_to_stdout: bool,
/// Per-tab performance probe injection. OFF by default: the probe
/// patches addEventListener/setTimeout/setInterval/XHR and runs a
/// permanent rAF loop, which adds enough main-thread overhead to
/// break scrolling on listener-heavy pages. Mirrors the C default
/// (settings.c:136).
pub perf_probe_enabled: bool,
}
impl Default for BrowserSettings {
@@ -69,7 +75,12 @@ impl Default for BrowserSettings {
enable_media_source: true,
enable_smooth_scrolling: true,
enable_accelerated_2d_canvas: true,
// Console-to-stdout is OFF by default: the browser's stdout is
// usually a terminal, where writes are slow and can block the
// WebProcess. Enable only when debugging.
enable_write_console_messages_to_stdout: false,
// Perf probe OFF by default (see field docs).
perf_probe_enabled: false,
}
}
}
@@ -122,6 +133,12 @@ pub fn settings_update(json: &serde_json::Value) {
if let Some(v) = json.get("theme_dark").and_then(|v| v.as_bool()) {
settings.theme_dark = v;
}
if let Some(v) = json.get("perf_probe_enabled").and_then(|v| v.as_bool()) {
settings.perf_probe_enabled = v;
}
if let Some(v) = json.get("enable_write_console_messages_to_stdout").and_then(|v| v.as_bool()) {
settings.enable_write_console_messages_to_stdout = v;
}
}
/// Load settings from the database.
@@ -149,6 +166,8 @@ pub fn settings_save() {
"agent_port": settings.agent_port,
"session_restore": settings.session_restore,
"theme_dark": settings.theme_dark,
"perf_probe_enabled": settings.perf_probe_enabled,
"enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout,
});
if let Ok(json_str) = serde_json::to_string(&json) {
+10 -2
View File
@@ -218,8 +218,16 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
state.active_tab_id = tab_id;
drop(state);
// Create the webview with perf probe injected (tab index baked into preamble).
let content_manager = crate::nostr_inject::nostr_inject_create_content_manager_with_probe(tab_id);
// Create the webview. The perf probe is injected only when explicitly
// enabled: it patches addEventListener/setTimeout/setInterval/XHR and
// runs a permanent rAF loop, which adds enough main-thread overhead to
// break scrolling on listener-heavy pages. Mirrors the C gate
// (tab_manager.c:1349). Default OFF.
let content_manager = if s.perf_probe_enabled {
crate::nostr_inject::nostr_inject_create_content_manager_with_probe(tab_id)
} else {
crate::nostr_inject::nostr_inject_create_content_manager()
};
let webview = WebView::builder()
.user_content_manager(&content_manager)
.web_context(ctx)
+2 -2
View File
@@ -1,7 +1,7 @@
//! Version information for sovereign_browser
/// The current version of sovereign_browser (with leading 'v').
pub const VERSION: &str = "v0.0.6";
pub const VERSION: &str = "v0.0.7";
/// 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 = 6;
pub const VERSION_PATCH: u32 = 7;
+7 -4
View File
@@ -50,8 +50,11 @@ pub fn web_context_configure_settings(settings: &webkit2gtk::Settings) {
// Deprecated since 2.32 with no replacement; keep for explicitness.
#[allow(deprecated)]
settings.set_enable_accelerated_2d_canvas(true);
// Enable console messages to stdout so we can debug window.nostr injection
// and page-level issues. The C version connects to console-message signal;
// this is simpler for now.
settings.set_enable_write_console_messages_to_stdout(true);
// Console-to-stdout is OFF by default. The browser's stdout is usually a
// terminal, where writes are slow and can block the WebProcess; on a
// chatty page this is a real CPU cost. Enable via the
// `enable_write_console_messages_to_stdout` setting when debugging.
settings.set_enable_write_console_messages_to_stdout(
crate::settings::settings_get().enable_write_console_messages_to_stdout,
);
}
+11
View File
@@ -145,6 +145,17 @@
onclick="toggle('universal_access')"></div>
</div>
<div class="setting">
<div>
<div class="setting-name">Performance Probe</div>
<div class="setting-desc">Per-tab CPU/FPS instrumentation. Adds main-thread
overhead (patches timers, listeners, XHR; runs a rAF loop). Off by
default. Takes effect on new tabs.</div>
</div>
<div class="toggle" data-feature="perf_probe_enabled"
onclick="toggle('perf_probe_enabled')"></div>
</div>
<div class="setting">
<div><div class="setting-name">CORS Enforcement</div></div>
<div class="toggle off" style="opacity:0.5"></div>
+2 -1
View File
@@ -49,7 +49,8 @@ function applyConfig(d) {
agent_server_enabled: d.agent_server_enabled,
dev_extras: d.dev_extras,
file_access: d.file_access,
universal_access: d.universal_access
universal_access: d.universal_access,
perf_probe_enabled: d.perf_probe_enabled
};
document.querySelectorAll('.toggle[data-feature]').forEach(function(el) {
var key = el.getAttribute('data-feature');