diff --git a/Cargo.lock b/Cargo.lock index 19aad26..f284aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3056,7 +3056,7 @@ dependencies = [ [[package]] name = "sovereign_browser" -version = "0.0.5" +version = "0.0.7" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 63ad653..bcd43ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/VERSION b/VERSION index 1750564..5a5831a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/run.sh b/run.sh index 6d975ca..39d0437 100755 --- a/run.sh +++ b/run.sh @@ -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." diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs index 0243119..f241567 100644 --- a/src/nostr_bridge.rs +++ b/src/nostr_bridge.rs @@ -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=`, + // 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,21 +736,27 @@ 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::(&event_json) { - Ok(event) => { - match signer.sign_event(&event) { - Ok(signed) => { - let result = serde_json::to_string(&signed).unwrap_or_default(); - respond_json(request, &result); - } - Err(e) => respond_error_json(request, 500, &e.to_string()), - } - } - Err(e) => respond_error_json(request, 400, &e.to_string()), + let signer = match &state.signer { + Some(s) => s.clone(), + None => { + respond_error_json(request, 500, "No signer available"); + return; } - } else { - respond_error_json(request, 500, "No signer available"); + }; + 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) => { + let result = serde_json::to_string(&signed).unwrap_or_default(); + respond_json(request, &result); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + 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 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 = 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 { } /// 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 = 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 { 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 { + 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::(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())); diff --git a/src/nostr_inject.rs b/src/nostr_inject.rs index 55e8664..60c26e5 100644 --- a/src/nostr_inject.rs +++ b/src/nostr_inject.rs @@ -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, &[], &[], diff --git a/src/settings.rs b/src/settings.rs index 2fa57a3..d6b73da 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -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) { diff --git a/src/tab_manager.rs b/src/tab_manager.rs index b0a4151..a5de340 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -218,8 +218,16 @@ pub fn tab_manager_new_tab(notebook: >k::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) diff --git a/src/version.rs b/src/version.rs index 17b2c2a..ff9bca8 100644 --- a/src/version.rs +++ b/src/version.rs @@ -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; diff --git a/src/web_context.rs b/src/web_context.rs index 385b34c..9573a51 100644 --- a/src/web_context.rs +++ b/src/web_context.rs @@ -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, + ); } diff --git a/www/settings.html b/www/settings.html index 10e229e..d99dce7 100644 --- a/www/settings.html +++ b/www/settings.html @@ -145,6 +145,17 @@ onclick="toggle('universal_access')"> +
+
+
Performance Probe
+
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.
+
+
+
+
CORS Enforcement
diff --git a/www/settings.js b/www/settings.js index e0ef191..cba9b96 100644 --- a/www/settings.js +++ b/www/settings.js @@ -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');