From c451af495c89b1dd85c75079ec48146c6459889c Mon Sep 17 00:00:00 2001 From: JSKitty Date: Mon, 16 Mar 2026 23:31:33 +0000 Subject: [PATCH 001/417] feat: replace iroh-gossip with raw QUIC for realtime channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of Mini App realtime P2P transport — replaces the iroh-gossip epidemic protocol with direct QUIC connections via iroh's Endpoint API. This eliminates all software bottlenecks, achieving ~20,000x faster local processing and ~99% reduction in per-message framing overhead. Key changes: - Raw QUIC uni streams replace gossip broadcast (no protocol overhead) - Per-peer independent connections eliminate head-of-line blocking - Persistent streams with varint length prefix (1-3 bytes vs 39 bytes) - WebSocket fast-path for sends on macOS/Windows (~1μs per message) - Tauri invoke fallback for Linux/WebKitGTK (WS blocked on custom schemes) - Public key tie-breaker prevents simultaneous connection storms - Stream announcement resolves QUIC lazy-open timing issues - 36-byte seq+pubkey trailer removed (QUIC identifies peers natively) - iroh-gossip dependency fully removed from the project New file: rt_ws.rs — localhost WS server with 128-bit token auth for zero-overhead binary frame sends from Mini App JS. Tested bidirectionally across macOS, Linux, and Android (emulator). Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 83 +-- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 2 +- src-tauri/capabilities/miniapp.json | 2 +- src-tauri/src/android/miniapp_jni.rs | 157 ++--- src-tauri/src/lib.rs | 2 +- src-tauri/src/miniapps/commands.rs | 58 +- src-tauri/src/miniapps/error.rs | 2 - src-tauri/src/miniapps/mod.rs | 1 + src-tauri/src/miniapps/realtime.rs | 829 ++++++++++++++++++++------- src-tauri/src/miniapps/rt_ws.rs | 241 ++++++++ src-tauri/src/miniapps/scheme.rs | 52 +- src-tauri/src/miniapps/state.rs | 2 +- 13 files changed, 966 insertions(+), 467 deletions(-) create mode 100644 src-tauri/src/miniapps/rt_ws.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e104eb48..66353119 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2003,12 +2003,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.9" @@ -2143,21 +2137,6 @@ dependencies = [ "futures-sink", ] -[[package]] -name = "futures-concurrency" -version = "7.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb68017df91f2e477ed4bea586c59eaecaa47ed885a770d0444e21e62572cd2" -dependencies = [ - "fixedbitset 0.5.7", - "futures-buffered", - "futures-core", - "futures-lite", - "pin-project", - "slab", - "smallvec", -] - [[package]] name = "futures-core" version = "0.3.31" @@ -3480,36 +3459,6 @@ dependencies = [ "zeroize_derive", ] -[[package]] -name = "iroh-gossip" -version = "0.96.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d04f83254c847ac61a9b2215b95a36d598d87af033ca12a546cd1c6a2e06dab" -dependencies = [ - "blake3", - "bytes", - "data-encoding", - "derive_more 2.1.1", - "ed25519-dalek 3.0.0-pre.1", - "futures-concurrency", - "futures-lite", - "futures-util", - "hex", - "indexmap 2.12.0", - "iroh", - "iroh-base", - "iroh-metrics", - "irpc", - "n0-error", - "n0-future", - "postcard", - "rand 0.9.2", - "serde", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "iroh-metrics" version = "0.38.2" @@ -3644,33 +3593,6 @@ dependencies = [ "z32", ] -[[package]] -name = "irpc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bbc84aaeab13a6d7502bae4f40f2517b643924842e0230ea0bf807477cc208" -dependencies = [ - "futures-util", - "irpc-derive", - "n0-error", - "n0-future", - "serde", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "irpc-derive" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58148196d2230183c9679431ac99b57e172000326d664e8456fa2cd27af6505a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -4491,7 +4413,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" dependencies = [ - "anyhow", "n0-error-macros", "spez", ] @@ -5789,7 +5710,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset 0.4.2", + "fixedbitset", "indexmap 2.12.0", ] @@ -9253,7 +9174,6 @@ dependencies = [ "http", "image", "iroh", - "iroh-gossip", "iroh-quinn-proto", "jni", "lofty", @@ -9293,6 +9213,7 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "tokio", + "tokio-tungstenite 0.28.0", "toml 0.8.23", "url", "whisper-rs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b4035ea5..d298c1ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,9 +79,9 @@ url = "2.5.7" # Realtime peer channels (Iroh P2P) iroh = "0.96" -iroh-gossip = "0.96" iroh-quinn-proto = "0.15" rand = "0.8" +tokio-tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } # PIVX Promos (addressless crypto payments) secp256k1 = "0.29" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e75bd74d..d080259b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -148,8 +148,8 @@ "allow-miniapp-send-update", "allow-miniapp-list-open", "allow-miniapp-join-realtime-channel", - "allow-miniapp-send-realtime-data", "allow-miniapp-leave-realtime-channel", + "allow-miniapp-send-realtime-data", "allow-miniapp-add-realtime-peer", "allow-miniapp-get-realtime-node-addr", "allow-miniapp-get-realtime-status", diff --git a/src-tauri/capabilities/miniapp.json b/src-tauri/capabilities/miniapp.json index f33c6a86..aed0050a 100644 --- a/src-tauri/capabilities/miniapp.json +++ b/src-tauri/capabilities/miniapp.json @@ -11,8 +11,8 @@ "allow-miniapp-get-updates", "allow-miniapp-send-update", "allow-miniapp-join-realtime-channel", - "allow-miniapp-send-realtime-data", "allow-miniapp-leave-realtime-channel", + "allow-miniapp-send-realtime-data", "allow-miniapp-get-granted-permissions-for-window", "notification:allow-is-permission-granted" ], diff --git a/src-tauri/src/android/miniapp_jni.rs b/src-tauri/src/android/miniapp_jni.rs index 099619f9..8795e0d1 100644 --- a/src-tauri/src/android/miniapp_jni.rs +++ b/src-tauri/src/android/miniapp_jni.rs @@ -20,18 +20,13 @@ use crate::TAURI_APP; /// - `default-src 'self'`: Only allow resources from same origin (webxdc.localhost) /// - `webrtc 'block'`: Prevent IP leaks via WebRTC /// - `unsafe-inline/eval`: Required for many Mini Apps to function -const CSP_HEADER: &str = r#"default-src 'self' http://webxdc.localhost; style-src 'self' http://webxdc.localhost 'unsafe-inline' blob:; font-src 'self' http://webxdc.localhost data: blob:; script-src 'self' http://webxdc.localhost 'unsafe-inline' 'unsafe-eval' blob:; connect-src 'self' http://webxdc.localhost ipc: data: blob:; img-src 'self' http://webxdc.localhost data: blob:; media-src 'self' http://webxdc.localhost data: blob:; webrtc 'block'"#; +const CSP_HEADER: &str = r#"default-src 'self' http://webxdc.localhost; style-src 'self' http://webxdc.localhost 'unsafe-inline' blob:; font-src 'self' http://webxdc.localhost data: blob:; script-src 'self' http://webxdc.localhost 'unsafe-inline' 'unsafe-eval' blob:; connect-src 'self' http://webxdc.localhost ws://127.0.0.1:* ipc: data: blob:; img-src 'self' http://webxdc.localhost data: blob:; media-src 'self' http://webxdc.localhost data: blob:; webrtc 'block'"#; /// Permissions Policy for Mini Apps (Android document responses). /// Autoplay is allowed (self) for video streaming in Mini Apps. /// Must be on the document response to take effect (not subresource responses). const PERMISSIONS_POLICY_HEADER: &str = "accelerometer=(), ambient-light-sensor=(), autoplay=(self), battery=(), bluetooth=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), speaker-selection=(), usb=(), web-share=(), xr-spatial-tracking=()"; -/// Maximum size for realtime channel data (128 KB). -/// This matches the WebXDC specification limit. -#[allow(dead_code)] -pub const REALTIME_DATA_MAX_SIZE: usize = 128_000; - // ============================================================================ // MiniAppManager Callbacks // ============================================================================ @@ -115,7 +110,7 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppManager_onMiniAppClosed( // Leave the Iroh gossip channel (with timeout to avoid hanging) match tokio::time::timeout( tokio::time::Duration::from_secs(5), - iroh.leave_channel(channel.topic), + iroh.leave_channel(channel.topic, &miniapp_id_owned), ).await { Ok(Ok(())) => { log_info!("[WEBXDC] Left Iroh channel on Mini App close: {}", miniapp_id_owned); @@ -345,9 +340,9 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative let state = app.state::(); - // Read instance, derive topic, and set channel state synchronously. - // Setting channel state here (before the async join) prevents a race where - // sendRealtimeData arrives before the spawned join task completes. + // Read instance, derive topic, set channel state, and eagerly init Iroh + WS server + // synchronously. Setting channel state before async join prevents races. + // Iroh init here ensures the WS URL is available for the return value. let setup = rt.block_on(async { let instance = state.get_instance(&miniapp_id).await .ok_or("Instance not found")?; @@ -367,20 +362,30 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative // If channel already active, just return the topic (skip re-join) if state.has_realtime_channel(&miniapp_id).await { log_info!("[WEBXDC] Android: Realtime channel already active for: {}", miniapp_id); - return Ok((None, topic, topic_encoded)); + let ws_url = state.realtime.ws_url(); + return Ok((None, topic, topic_encoded, ws_url)); } - // Set channel state immediately so sendRealtimeData can find the topic + // Set channel state immediately state.set_realtime_channel(&miniapp_id, crate::miniapps::state::RealtimeChannelState { topic, active: true, }).await; - Ok::<_, String>((Some(instance), topic, topic_encoded)) + // Eagerly init Iroh + WS server so we can return the WS URL + let ws_url = match state.realtime.get_or_init().await { + Ok(_) => state.realtime.ws_url(), + Err(e) => { + log_warn!("[WEBXDC] Android: Failed to eagerly init Iroh: {}", e); + None + } + }; + + Ok::<_, String>((Some(instance), topic, topic_encoded, ws_url)) }); drop(rt); - let (instance_opt, topic, topic_encoded) = match setup { + let (instance_opt, topic, topic_encoded, ws_url) = match setup { Ok(r) => r, Err(e) => { log_error!("[{}] joinRealtimeChannel setup failed: {}", miniapp_id, e); @@ -388,11 +393,12 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative } }; - // If already active, return existing topic without spawning new tasks + // If already active, return existing result without spawning new tasks let instance = match instance_opt { Some(inst) => inst, None => { - return match env.new_string(&topic_encoded) { + let result = serde_json::json!({ "topic": topic_encoded, "ws_url": ws_url, "label": miniapp_id }); + return match env.new_string(&result.to_string()) { Ok(s) => s.into_raw(), Err(_) => std::ptr::null_mut(), }; @@ -424,7 +430,7 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative // Join the channel with mpsc event target let event_target = crate::miniapps::realtime::EventTarget::MpscSender(tx); - match iroh.join_channel(topic, vec![], event_target, Some(app_for_join.clone())).await { + match iroh.join_channel(topic, vec![], event_target, Some(app_for_join.clone()), miniapp_id_for_join.clone()).await { Ok((is_rejoin, _)) => { if is_rejoin { log_info!("[WEBXDC] Android: Re-joined existing channel for topic: {}", topic_encoded_for_join); @@ -504,95 +510,14 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative // Spawn the delivery task that forwards events to Android WebView via JNI tauri::async_runtime::spawn(android_realtime_delivery_loop(rx)); - // Return topic ID to Kotlin - match env.new_string(&topic_encoded) { + // Return JSON with topic + WS URL + label to Kotlin/JS + let result = serde_json::json!({ "topic": topic_encoded, "ws_url": ws_url, "label": miniapp_id }); + match env.new_string(&result.to_string()) { Ok(s) => s.into_raw(), Err(_) => std::ptr::null_mut(), } } -/// Send realtime data. -#[no_mangle] -pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_sendRealtimeDataNative( - mut env: JNIEnv, - _class: JClass, - miniapp_id: JString, - data: JString, -) { - let miniapp_id: String = match env.get_string(&miniapp_id) { - Ok(s) => s.into(), - Err(e) => { - log_error!("Failed to get miniapp_id: {:?}", e); - return; - } - }; - - let encoded: String = match env.get_string(&data) { - Ok(s) => s.into(), - Err(e) => { - log_error!("Failed to get data string: {:?}", e); - return; - } - }; - - // Decode base91 to raw bytes - let bytes = match fast_thumbhash::base91_decode(&encoded) { - Ok(b) => b, - Err(_) => { - log_error!("[{}] sendRealtimeData: failed to decode base91", miniapp_id); - return; - } - }; - - log_debug!( - "[{}] sendRealtimeData: {} bytes", - miniapp_id, - bytes.len() - ); - - // Validate data size - if bytes.len() > REALTIME_DATA_MAX_SIZE { - log_error!("[{}] sendRealtimeData: data too large ({} bytes)", miniapp_id, bytes.len()); - return; - } - - let app = match TAURI_APP.get() { - Some(a) => a.clone(), - None => { - log_error!("[{}] TAURI_APP not initialized", miniapp_id); - return; - } - }; - - let miniapp_id_owned = miniapp_id; - let data = bytes; - tauri::async_runtime::spawn(async move { - let state = app.state::(); - - // Get the topic for this instance - let topic = match state.get_realtime_channel(&miniapp_id_owned).await { - Some(t) => t, - None => { - log_warn!("[WEBXDC] Android sendRealtimeData: no active channel for {}", miniapp_id_owned); - return; - } - }; - - // Send via Iroh - let iroh = match state.realtime.get_or_init().await { - Ok(iroh) => iroh, - Err(e) => { - log_error!("[WEBXDC] Android sendRealtimeData: failed to get Iroh: {}", e); - return; - } - }; - - if let Err(e) = iroh.send_data(topic, data).await { - log_error!("[WEBXDC] Android sendRealtimeData: failed to send: {}", e); - } - }); -} - /// Leave the realtime channel. #[no_mangle] pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_leaveRealtimeChannelNative( @@ -632,7 +557,7 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_leaveRealtimeChannelNativ } }; - if let Err(e) = iroh.leave_channel(channel_state.topic).await { + if let Err(e) = iroh.leave_channel(channel_state.topic, &miniapp_id_owned).await { log_error!("[WEBXDC] Android leaveRealtimeChannel: failed to leave: {}", e); } else { log_info!("[WEBXDC] Android: Left realtime channel for {}", miniapp_id_owned); @@ -1205,6 +1130,7 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { }}, joinRealtimeChannel: function() {{ + var rtWs = null; var channel = {{ _listener: null, setListener: function(listener) {{ @@ -1212,21 +1138,14 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { window.__miniapp_realtime_listener = listener; }}, send: function(data) {{ - if (!(data instanceof Uint8Array)) {{ - throw new Error('realtime data must be a Uint8Array'); - }} - if (data.length > 128000) {{ - throw new Error('realtime data exceeds maximum size of 128000 bytes'); - }} - try {{ - window.__MINIAPP_IPC__.sendRealtimeData(b91e(data)); - }} catch(e) {{ - console.error('Failed to send realtime data:', e); - }} + var buf = data instanceof Uint8Array ? data : new Uint8Array(data); + // WebSocket fast path — silently drop if not connected yet + if (rtWs && rtWs.readyState === 1) rtWs.send(buf); }}, leave: function() {{ this._listener = null; window.__miniapp_realtime_listener = null; + if (rtWs) {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }} try {{ window.__MINIAPP_IPC__.leaveRealtimeChannel(); }} catch(e) {{ @@ -1235,7 +1154,17 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { }} }}; try {{ - window.__MINIAPP_IPC__.joinRealtimeChannel(); + var resultJson = window.__MINIAPP_IPC__.joinRealtimeChannel(); + if (resultJson) {{ + var result = JSON.parse(resultJson); + if (result.ws_url && result.label) {{ + var label = encodeURIComponent(result.label); + rtWs = new WebSocket(result.ws_url + '/' + label); + rtWs.binaryType = 'arraybuffer'; + rtWs.onclose = function() {{ rtWs = null; }}; + rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }}; + }} + }} }} catch(e) {{ console.error('Failed to join realtime channel:', e); }} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5721491f..30ffb322 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -439,8 +439,8 @@ pub fn run() { miniapps::commands::miniapp_list_open, // Mini Apps realtime channel commands (Iroh P2P) miniapps::commands::miniapp_join_realtime_channel, - miniapps::commands::miniapp_send_realtime_data, miniapps::commands::miniapp_leave_realtime_channel, + miniapps::commands::miniapp_send_realtime_data, miniapps::commands::miniapp_add_realtime_peer, miniapps::commands::miniapp_get_realtime_node_addr, miniapps::commands::miniapp_get_realtime_status, diff --git a/src-tauri/src/miniapps/commands.rs b/src-tauri/src/miniapps/commands.rs index 5eff864e..abe21f30 100644 --- a/src-tauri/src/miniapps/commands.rs +++ b/src-tauri/src/miniapps/commands.rs @@ -485,8 +485,8 @@ try { 'miniapp_get_updates', 'miniapp_send_update', 'miniapp_join_realtime_channel', - 'miniapp_send_realtime_data', 'miniapp_leave_realtime_channel', + 'miniapp_send_realtime_data', 'miniapp_add_realtime_peer', 'miniapp_get_realtime_node_addr', 'miniapp_get_granted_permissions_for_window' @@ -1113,15 +1113,24 @@ fn md5_hash(input: &str) -> u64 { // Realtime Channel Commands (Iroh P2P) // ============================================================================ +/// Result of joining a realtime channel +#[derive(Serialize)] +pub struct JoinRealtimeResult { + /// Encoded topic ID + pub topic: String, + /// WebSocket URL for the zero-overhead fast path (if WS server is running) + pub ws_url: Option, +} + /// Join the realtime channel for a Mini App -/// Returns the topic ID that can be shared with other participants +/// Returns the topic ID and WebSocket URL for the fast-path send #[tauri::command] pub async fn miniapp_join_realtime_channel( window: WebviewWindow, app: AppHandle, state: State<'_, MiniAppsState>, channel: Channel, -) -> Result { +) -> Result { let label = window.label(); if !label.starts_with("miniapp:") { @@ -1153,7 +1162,7 @@ pub async fn miniapp_join_realtime_channel( // Join the Iroh gossip channel with no initial peers // Peers will be added via advertisements let event_target = EventTarget::TauriChannel(channel.clone()); - let (is_rejoin, _join_rx) = iroh.join_channel(topic, vec![], event_target, Some(app.clone())).await + let (is_rejoin, _join_rx) = iroh.join_channel(topic, vec![], event_target, Some(app.clone()), label.to_string()).await .map_err(|e| Error::RealtimeError(e.to_string()))?; let topic_encoded = encode_topic_id(&topic); @@ -1237,45 +1246,24 @@ pub async fn miniapp_join_realtime_channel( "is_active": true, })); } - - Ok(topic_encoded) + + // Return the topic + WebSocket URL for the fast-path send + let ws_url = state.realtime.ws_url(); + Ok(JoinRealtimeResult { topic: topic_encoded, ws_url }) } -/// Send data through the realtime channel (receives base91-encoded string from JS) +/// Send realtime data (invoke fallback for platforms where WebSocket is unavailable, e.g. Linux/WebKitGTK). +/// On macOS/Windows the WS fast-path is used instead; this command is only called when WS fails. #[tauri::command] pub async fn miniapp_send_realtime_data( window: WebviewWindow, state: State<'_, MiniAppsState>, - data: String, + data: Vec, ) -> Result<(), Error> { let label = window.label(); - - if !label.starts_with("miniapp:") { - return Err(Error::InstanceNotFoundByLabel(label.to_string())); - } - - // Decode base91 string to raw bytes - let bytes = fast_thumbhash::base91_decode(&data) - .map_err(|_| Error::RealtimeError("Invalid base91 encoding".to_string()))?; - - // Check data size (max 128 KB as per WebXDC spec) - if bytes.len() > 128_000 { - return Err(Error::RealtimeDataTooLarge(bytes.len())); + if let Ok(iroh) = state.realtime.get_or_init().await { + iroh.fast_send(label, data); } - - // Get the topic for this instance - let topic = state.get_realtime_channel(label).await - .ok_or(Error::RealtimeChannelNotActive)?; - - // Send the data - let iroh = state.realtime.get_or_init().await - .map_err(|e| Error::RealtimeError(e.to_string()))?; - - iroh.send_data(topic, bytes).await - .map_err(|e| Error::RealtimeError(e.to_string()))?; - - log_trace!("Sent realtime data for Mini App: {}", label); - Ok(()) } @@ -1297,7 +1285,7 @@ pub async fn miniapp_leave_realtime_channel( let iroh = state.realtime.get_or_init().await .map_err(|e| Error::RealtimeError(e.to_string()))?; - iroh.leave_channel(channel_state.topic).await + iroh.leave_channel(channel_state.topic, label).await .map_err(|e| Error::RealtimeError(e.to_string()))?; log_info!("Left realtime channel for Mini App: {} (topic: {})", label, encode_topic_id(&channel_state.topic)); diff --git a/src-tauri/src/miniapps/error.rs b/src-tauri/src/miniapps/error.rs index 5d106848..a524caa8 100644 --- a/src-tauri/src/miniapps/error.rs +++ b/src-tauri/src/miniapps/error.rs @@ -15,7 +15,6 @@ pub enum Error { Anyhow(anyhow::Error), RealtimeChannelAlreadyActive, RealtimeChannelNotActive, - RealtimeDataTooLarge(usize), RealtimeError(String), DatabaseError(String), } @@ -35,7 +34,6 @@ impl std::fmt::Display for Error { Error::Anyhow(e) => write!(f, "{}", e), Error::RealtimeChannelAlreadyActive => write!(f, "Realtime channel already active - call leave() first"), Error::RealtimeChannelNotActive => write!(f, "Realtime channel not active - call joinRealtimeChannel() first"), - Error::RealtimeDataTooLarge(size) => write!(f, "Realtime data too large: {} bytes (max 128000)", size), Error::RealtimeError(s) => write!(f, "Realtime channel error: {}", s), Error::DatabaseError(s) => write!(f, "Database error: {}", s), } diff --git a/src-tauri/src/miniapps/mod.rs b/src-tauri/src/miniapps/mod.rs index ca064368..5d8e6e57 100644 --- a/src-tauri/src/miniapps/mod.rs +++ b/src-tauri/src/miniapps/mod.rs @@ -14,5 +14,6 @@ pub(crate) mod state; pub(crate) mod commands; pub(crate) mod network_isolation; pub(crate) mod realtime; +pub(crate) mod rt_ws; pub(crate) mod marketplace; pub(crate) mod permissions; \ No newline at end of file diff --git a/src-tauri/src/miniapps/realtime.rs b/src-tauri/src/miniapps/realtime.rs index b59a841f..50819843 100644 --- a/src-tauri/src/miniapps/realtime.rs +++ b/src-tauri/src/miniapps/realtime.rs @@ -1,7 +1,7 @@ -//! Realtime peer channels for Mini Apps using Iroh +//! Realtime peer channels for Mini Apps using raw QUIC connections via Iroh. //! -//! This module provides P2P realtime communication for WebXDC apps using Iroh, -//! matching DeltaChat's implementation for cross-compatibility. +//! Each peer gets independent QUIC connections with per-peer send queues — +//! one slow peer cannot block others (no head-of-line blocking). //! //! See: https://webxdc.org/docs/spec/joinRealtimeChannel.html @@ -9,14 +9,10 @@ use anyhow::{anyhow, bail, Context as _, Result}; use fast_thumbhash::base91_encode; -use futures_util::StreamExt; use iroh::endpoint::VarInt; use iroh::{EndpointAddr, Endpoint, PublicKey, RelayMode, SecretKey, TransportAddr}; -use iroh_gossip::api::{Event, GossipReceiver, GossipSender, JoinOptions}; -use iroh_gossip::net::{Gossip, GOSSIP_ALPN}; -use iroh_gossip::proto::TopicId; use std::collections::HashMap; -use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager}; @@ -65,36 +61,103 @@ fn base32_nopad_decode(encoded: &[u8]) -> std::result::Result, String> { Ok(out) } +// ─── Constants ────────────────────────────────────────────────────────────── + +/// Custom ALPN protocol for Vector realtime P2P channels. +const VECTOR_RT_ALPN: &[u8] = b"vector-rt/1"; + /// Maximum message size for realtime data (128 KB as per WebXDC spec) const MAX_MESSAGE_SIZE: usize = 128 * 1024; +/// Bounded global send queue capacity — large enough for bursts, bounded to cap memory. +/// At 128 KB max payload, worst case is 256 * 128 KB = 32 MB buffered. +const SEND_QUEUE_CAPACITY: usize = 256; + +/// Per-peer send queue capacity. Smaller than the global queue because +/// a single slow peer should not buffer unboundedly. +/// At 128 KB max, worst case is 64 * 128 KB = 8 MB per peer. +const PEER_SEND_QUEUE_CAPACITY: usize = 64; + /// The length of an ed25519 PublicKey, in bytes. -const PUBLIC_KEY_LENGTH: usize = 32; +pub(crate) const PUBLIC_KEY_LENGTH: usize = 32; + +// ─── TopicId ──────────────────────────────────────────────────────────────── + +/// Topic identifier for realtime channels (32-byte hash). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TopicId([u8; 32]); + +impl TopicId { + pub fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } + pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } +} + +// ─── SendHandle ───────────────────────────────────────────────────────────── + +/// Sync-accessible handle for the zero-overhead send fast path. +/// Cached per window label so the WS server can skip all async lookups. +pub(crate) struct SendHandle { + pub send_tx: tokio::sync::mpsc::Sender>, + pub drops: Arc, +} + +impl std::fmt::Debug for SendHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendHandle") + .field("drops", &self.drops.load(Ordering::Relaxed)) + .finish() + } +} + +// ─── PeerConnection ───────────────────────────────────────────────────────── + +/// A single peer's QUIC connection and associated tasks. +struct PeerConnection { + /// The QUIC connection to this peer. + conn: iroh::endpoint::Connection, + /// Background task reading incoming uni streams from this peer. + read_task: JoinHandle<()>, + /// Background task writing to uni streams for this peer. + write_task: JoinHandle<()>, + /// Per-peer send queue (drainer fans out here; non-blocking try_send). + send_tx: tokio::sync::mpsc::Sender>, +} + +// ─── IrohState ────────────────────────────────────────────────────────────── /// Store Iroh peer channels state -#[derive(Debug)] pub struct IrohState { - /// Iroh endpoint for peer channels + /// Iroh QUIC endpoint for P2P connections pub(crate) endpoint: Endpoint, - /// Gossip protocol handler - pub(crate) gossip: Gossip, - - /// Active realtime channels - pub(crate) channels: RwLock>, + /// Active realtime channels (Arc for sharing with accept loop, drainer, and peer tasks) + pub(crate) channels: Arc>>, /// Our public key (attached to messages for deduplication) pub(crate) public_key: PublicKey, /// Cached public key bytes (avoids repeated .as_bytes() calls in hot path) pub(crate) public_key_bytes: [u8; PUBLIC_KEY_LENGTH], + + /// Fast-path send handles keyed by window label (sync access via std RwLock). + /// Wrapped in Arc so the WS server can share the same map. + /// Populated on join_channel, removed on leave_channel. + pub(crate) send_handles: Arc>>, +} + +impl std::fmt::Debug for IrohState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IrohState") + .field("public_key", &self.public_key) + .finish() + } } impl IrohState { - /// Initialize a new Iroh state with endpoint and gossip + /// Initialize a new Iroh state with QUIC endpoint (no gossip) pub async fn new(_relay_url: Option) -> Result { - log_info!("Initializing Iroh peer channels"); - + log_info!("Initializing Iroh peer channels (raw QUIC)"); + // Generate 32 random bytes and construct SecretKey from them // (avoids rand_core version mismatch between our rand 0.8 and iroh's rand_core 0.9) let mut key_bytes = [0u8; 32]; @@ -123,10 +186,10 @@ impl IrohState { )) .build(); - // Build the endpoint with tuned transport + // Build the endpoint with our custom ALPN let endpoint = Endpoint::builder() .secret_key(secret_key) - .alpns(vec![GOSSIP_ALPN.to_vec()]) + .alpns(vec![VECTOR_RT_ALPN.to_vec()]) .relay_mode(RelayMode::Default) .transport_config(transport_config) .bind() @@ -135,28 +198,27 @@ impl IrohState { // Relay connects automatically in the background (no manual wait needed) log_info!("[WEBXDC] Endpoint bound, relay connection will establish in background"); - // Create gossip with max message size of 128 KB - let gossip = Gossip::builder() - .max_message_size(MAX_MESSAGE_SIZE) - .spawn(endpoint.clone()); + let public_key_bytes = *public_key.as_bytes(); + let channels: Arc>> = Arc::new(RwLock::new(HashMap::new())); - // Start the accept loop to handle incoming connections - // The gossip protocol doesn't accept connections itself - we need to do it + // Start the accept loop to handle incoming peer connections let accept_endpoint = endpoint.clone(); - let accept_gossip = gossip.clone(); + let accept_channels = channels.clone(); + let accept_our_key = public_key_bytes; tokio::spawn(async move { log_info!("[WEBXDC] Starting connection accept loop"); loop { match accept_endpoint.accept().await { Some(incoming) => { - let gossip = accept_gossip.clone(); + let channels = accept_channels.clone(); tokio::spawn(async move { match incoming.await { Ok(conn) => { - if conn.alpn() == GOSSIP_ALPN { - if let Err(e) = gossip.handle_connection(conn).await { - log_error!("[WEBXDC] Failed to handle gossip connection: {}", e); - } + if conn.alpn() != VECTOR_RT_ALPN { + return; + } + if let Err(e) = handle_incoming_peer(conn, channels, accept_our_key).await { + log_warn!("[WEBXDC] Failed to handle incoming peer: {e}"); } } Err(e) => { @@ -173,14 +235,12 @@ impl IrohState { } }); - let public_key_bytes = *public_key.as_bytes(); - Ok(Self { endpoint, - gossip, - channels: RwLock::new(HashMap::new()), + channels, public_key, public_key_bytes, + send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())), }) } @@ -205,45 +265,94 @@ impl IrohState { EndpointAddr { id: addr.id, addrs: filtered_addrs } } - /// Join a gossip topic and start the subscriber loop + /// Join a realtime channel and start per-peer tasks. + /// `label` is the window label (e.g. "miniapp:abc123") used for fast-path send caching. pub async fn join_channel( &self, topic: TopicId, peers: Vec, event_target: EventTarget, app_handle: Option, + label: String, ) -> Result<(bool, Option>)> { let mut channels = self.channels.write().await; // If channel already exists, we're re-joining (e.g., user closed and reopened the game) - // Update the shared event target so the subscribe loop uses the new frontend channel + // Update the shared event target so the reader tasks use the new frontend channel if let Some(channel_state) = channels.get(&topic) { - log_info!("IROH_REALTIME: Re-joining existing gossip topic {:?}, updating event target", topic); + log_info!("IROH_REALTIME: Re-joining existing topic {:?}, updating event target", topic); let mut shared_target = channel_state.event_target.write().unwrap_or_else(|e| e.into_inner()); *shared_target = Some(event_target); return Ok((true, None)); } - let peer_ids: Vec = peers.iter().map(|p| p.id).collect(); - log_info!( - "IROH_REALTIME: Joining gossip topic {:?} with {} peers", + "IROH_REALTIME: Joining topic {:?} with {} peers", topic, - peer_ids.len() + peers.len() ); - // Connect to peers so gossip can discover them - for peer_addr in &peers { + // Create shared state + let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(Some(event_target))); + let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0)); + + // Bounded send queue: JS invoke returns instantly, drainer fans out in background + let (send_tx, send_rx) = tokio::sync::mpsc::channel::>(SEND_QUEUE_CAPACITY); + let drops = Arc::new(AtomicU64::new(0)); + + // Spawn the fan-out drainer task + let drainer_channels = self.channels.clone(); + let drainer_topic = topic; + let drainer_drops = drops.clone(); + let drainer_handle = tokio::spawn(async move { + run_send_drainer(send_rx, drainer_channels, drainer_topic, drainer_drops).await; + }); + + channels.insert(topic, ChannelState { + peers: HashMap::new(), + drainer_handle, + send_tx: send_tx.clone(), + event_target: shared_event_target.clone(), + peer_count: shared_peer_count.clone(), + drops: drops.clone(), + }); + + // Drop channels lock before connecting to peers (connections may take time) + drop(channels); + + // Populate fast-path send handle for the WS server (zero-overhead sends) + self.send_handles.write().unwrap_or_else(|e| e.into_inner()).insert(label, SendHandle { + send_tx, + drops, + }); + + // Connect to initial peers concurrently + let (join_tx, join_rx) = oneshot::channel(); + let join_tx = Arc::new(std::sync::Mutex::new(Some(join_tx))); + + for peer_addr in peers { if !peer_addr.addrs.is_empty() { - let addr = peer_addr.clone(); let ep = self.endpoint.clone(); - let g = self.gossip.clone(); + let channels_ref = self.channels.clone(); + let et = shared_event_target.clone(); + let pc = shared_peer_count.clone(); + let our_key = self.public_key_bytes; + let join_tx_clone = join_tx.clone(); + let app_handle_clone = app_handle.clone(); + let topic_encoded = encode_topic_id(&topic); + tokio::spawn(async move { - match ep.connect(addr, GOSSIP_ALPN).await { - Ok(conn) => { - if let Err(e) = g.handle_connection(conn).await { - log_warn!("[WEBXDC] Failed to handle peer connection: {e}"); + match connect_to_peer(&ep, peer_addr, topic, et.clone(), pc.clone(), our_key, channels_ref).await { + Ok(_) => { + // Signal connected on first peer + if let Ok(mut guard) = join_tx_clone.lock() { + if let Some(tx) = guard.take() { + let _ = tx.send(()); + send_event(&et, RealtimeEvent::Connected); + } } + let new_count = pc.load(Ordering::Relaxed); + emit_realtime_status(&app_handle_clone, &topic_encoded, new_count, true); } Err(e) => log_warn!("[WEBXDC] Failed to connect to peer: {e}"), } @@ -251,32 +360,6 @@ impl IrohState { } } - let (join_tx, join_rx) = oneshot::channel(); - - let gossip_topic = self - .gossip - .subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids)) - .await?; - let (gossip_sender, gossip_receiver) = gossip_topic.split(); - - // Create shared event target for the subscribe loop - let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(Some(event_target))); - let shared_target_clone = shared_event_target.clone(); - - // Create shared peer count - let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0)); - let peer_count_clone = shared_peer_count.clone(); - - let our_key_bytes = self.public_key_bytes; - let topic_encoded = encode_topic_id(&topic); - let subscribe_loop = tokio::spawn(async move { - if let Err(e) = run_subscribe_loop(gossip_receiver, topic, shared_target_clone, join_tx, our_key_bytes, peer_count_clone, app_handle, topic_encoded).await { - log_warn!("Subscribe loop failed: {e}"); - } - }); - - channels.insert(topic, ChannelState::new(subscribe_loop, gossip_sender, shared_event_target, shared_peer_count)); - Ok((false, Some(join_rx))) } @@ -285,7 +368,7 @@ impl IrohState { self.add_peer_with_retry(topic, peer, 3).await } - /// Add a peer to a gossip topic with retry logic + /// Add a peer to a topic with retry logic /// Retries with exponential backoff: 1s, 2s, 4s async fn add_peer_with_retry(&self, topic: TopicId, peer: EndpointAddr, max_retries: u32) -> Result<()> { let mut last_error = None; @@ -316,62 +399,52 @@ impl IrohState { peer.id, peer.addrs.len()); - // Connect to the peer and hand the connection to gossip - let conn = self.endpoint.connect(peer.clone(), GOSSIP_ALPN).await?; - self.gossip.handle_connection(conn).await?; - - // Verify the connection via remote_info - if let Some(_info) = self.endpoint.remote_info(peer.id).await { - log_trace!("[WEBXDC] add_peer: Remote info confirmed for peer {}", peer.id); - } else { - log_trace!("[WEBXDC] add_peer: WARNING - Could not get remote info for peer {}", peer.id); - } - - // Then, use the existing channel's sender to join the peer - let channels = self.channels.read().await; - if let Some(channel_state) = channels.get(topic) { - log_trace!("[WEBXDC] add_peer: Joining peer {} via existing channel sender", peer.id); - channel_state.sender.join_peers(vec![peer.id]).await?; - log_info!("[WEBXDC] add_peer: Successfully joined peer {} to topic", peer.id); - } else { - return Err(anyhow!("Channel not found for topic")); - } - Ok(()) - } - - /// Send data to a gossip topic - pub async fn send_data(&self, topic: TopicId, mut data: Vec) -> Result<()> { - // Clone sender and read seq under the lock, then release before broadcast. - // This prevents holding the read lock during potentially slow network I/O - // (backpressure under 4K video streaming, gaming, voice/video loads). - let sender = { + let (event_target, peer_count) = { let channels = self.channels.read().await; - let state = channels - .get(&topic) + let channel = channels.get(topic) .ok_or_else(|| anyhow!("Channel not found for topic"))?; - - // Pre-allocate for trailer: 4-byte seq + 32-byte public key - data.reserve(4 + PUBLIC_KEY_LENGTH); - let seq_num = state.seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1); - data.extend_from_slice(&seq_num.to_le_bytes()); - data.extend_from_slice(&self.public_key_bytes); - - state.sender.clone() + (channel.event_target.clone(), channel.peer_count.clone()) }; - sender.broadcast(data.into()).await?; - - log_trace!("Sent realtime data to topic {:?}", topic); + connect_to_peer( + &self.endpoint, peer.clone(), *topic, + event_target, peer_count, + self.public_key_bytes, self.channels.clone(), + ).await?; + log_info!("[WEBXDC] add_peer: Successfully connected peer {} to topic", peer.id); Ok(()) } + /// Zero-overhead send via the fast-path cache (entirely synchronous). + /// Called from the WS server and invoke fallback — no async, no JSON, no encoding. + pub fn fast_send(&self, label: &str, data: Vec) { + let handles = self.send_handles.read().unwrap_or_else(|e| e.into_inner()); + let Some(handle) = handles.get(label) else { return }; + + // Non-blocking enqueue — drops packet on overload + if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = handle.send_tx.try_send(data) { + handle.drops.fetch_add(1, Ordering::Relaxed); + } + } + /// Leave a realtime channel - pub async fn leave_channel(&self, topic: TopicId) -> Result<()> { + pub async fn leave_channel(&self, topic: TopicId, label: &str) -> Result<()> { + // Remove fast-path send handle + self.send_handles.write().unwrap_or_else(|e| e.into_inner()).remove(label); + if let Some(channel) = self.channels.write().await.remove(&topic) { - // Abort the subscribe loop (this drops the receiver) - channel.subscribe_loop.abort(); - let _ = channel.subscribe_loop.await; + // Abort drainer + channel.drainer_handle.abort(); + let _ = channel.drainer_handle.await; + + // Close all peer connections and abort their tasks + for (_key, peer) in channel.peers { + peer.conn.close(0u32.into(), b"leaving"); + peer.read_task.abort(); + peer.write_task.abort(); + } + log_info!("Left realtime channel {:?}", topic); } Ok(()) @@ -389,7 +462,7 @@ impl IrohState { /// Clear the event target for a topic (e.g., when the window is destroyed but /// the Iroh channel is intentionally kept alive for peer count tracking). - /// Prevents the subscribe loop from logging errors on every received message. + /// Prevents the reader tasks from logging errors on every received message. pub async fn clear_event_target(&self, topic: &TopicId) { let channels = self.channels.read().await; if let Some(channel_state) = channels.get(topic) { @@ -405,6 +478,8 @@ impl IrohState { } +// ─── EventTarget / RealtimeEvent ──────────────────────────────────────────── + /// Target for delivering realtime events (abstracts desktop vs Android) #[derive(Clone)] pub enum EventTarget { @@ -417,50 +492,42 @@ pub enum EventTarget { /// Shared event target that can be updated when a user re-joins. /// Uses std::sync::RwLock (not tokio) because the lock is held for <1μs /// (just dispatching one event) and this avoids async runtime overhead -/// on every received message in the subscribe loop hot path. +/// on every received message in the reader hot path. pub(crate) type SharedEventTarget = Arc>>; -/// Shared peer count that can be updated by the subscribe loop +/// Shared peer count that can be updated by reader tasks pub(crate) type SharedPeerCount = Arc; -/// State for a single gossip channel +// ─── ChannelState ─────────────────────────────────────────────────────────── + +/// State for a single realtime channel (one per TopicId) pub(crate) struct ChannelState { - /// Handle to the subscribe loop task - subscribe_loop: JoinHandle<()>, - /// Sender for broadcasting messages - sender: GossipSender, + /// Per-peer connections, keyed by remote PublicKey + peers: HashMap, + /// Handle to the fan-out drainer task + drainer_handle: JoinHandle<()>, + /// Bounded send queue — `try_send()` returns instantly, drainer fans out in background + send_tx: tokio::sync::mpsc::Sender>, /// Shared event target (can be updated on re-join) event_target: SharedEventTarget, /// Current number of connected peers peer_count: SharedPeerCount, - /// Sequence number for deduplication (lock-free) - seq: AtomicI32, + /// Dropped packet counter (overload detection, shared with SendHandle) + drops: Arc, } impl std::fmt::Debug for ChannelState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ChannelState") - .field("subscribe_loop", &"JoinHandle<()>") - .field("sender", &"GossipSender") + .field("peers", &self.peers.len()) + .field("drainer_handle", &"JoinHandle<()>") .field("event_target", &"SharedEventTarget") .field("peer_count", &self.peer_count.load(Ordering::Relaxed)) - .field("seq", &self.seq.load(Ordering::Relaxed)) + .field("drops", &self.drops.load(Ordering::Relaxed)) .finish() } } -impl ChannelState { - fn new(subscribe_loop: JoinHandle<()>, sender: GossipSender, event_target: SharedEventTarget, peer_count: SharedPeerCount) -> Self { - Self { - subscribe_loop, - sender, - event_target, - peer_count, - seq: AtomicI32::new(0), - } - } -} - /// Events sent to the frontend via Tauri channel #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase", tag = "event", content = "data")] @@ -473,10 +540,12 @@ pub enum RealtimeEvent { PeerJoined(String), /// A peer left the channel PeerLeft(String), - /// Gossip stream lagged — some messages were lost (app should request resync) + /// Messages were lost (app should request resync) Lagged, } +// ─── Event helpers ────────────────────────────────────────────────────────── + /// Helper to send an event through the shared event target (sync — no async overhead) fn send_event(shared_target: &SharedEventTarget, event: RealtimeEvent) -> bool { let guard = shared_target.read().unwrap_or_else(|e| e.into_inner()); @@ -521,89 +590,382 @@ fn emit_realtime_status(app_handle: &Option, topic_encoded: &str, pee } } -/// Run the subscribe loop for a gossip topic -async fn run_subscribe_loop( - mut receiver: GossipReceiver, - topic: TopicId, - shared_event_target: SharedEventTarget, - join_tx: oneshot::Sender<()>, +// ─── Peer connection management ───────────────────────────────────────────── + +/// Handle an incoming peer connection from the accept loop. +/// The peer opens a bi stream and sends a 32-byte topic ID. +async fn handle_incoming_peer( + conn: iroh::endpoint::Connection, + channels: Arc>>, our_key_bytes: [u8; PUBLIC_KEY_LENGTH], +) -> Result<()> { + let peer_key = conn.remote_id(); + + // Read topic ID from the control bi stream (with timeout to prevent stalling) + let (_ctrl_send, mut ctrl_recv) = tokio::time::timeout( + std::time::Duration::from_secs(5), + conn.accept_bi(), + ).await + .map_err(|_| anyhow!("Timeout waiting for topic handshake"))? + .map_err(|e| anyhow!("Failed to accept bi stream: {e}"))?; + + let mut topic_bytes = [0u8; 32]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + ctrl_recv.read_exact(&mut topic_bytes), + ).await + .map_err(|_| anyhow!("Timeout reading topic ID"))? + .map_err(|e| anyhow!("Failed to read topic ID: {e}"))?; + + let topic = TopicId::from_bytes(topic_bytes); + log_info!("[WEBXDC] Incoming peer {} for topic {:?}", peer_key, topic); + + let mut channels_guard = channels.write().await; + let Some(channel) = channels_guard.get_mut(&topic) else { + conn.close(0u32.into(), b"unknown topic"); + bail!("Incoming peer for unknown topic {:?}", topic); + }; + + // Tie-breaker for simultaneous connections: if we already have a connection + // to this peer, the side with the higher public key wins (keeps their outgoing). + // We're the acceptor here (incoming connection), so we should only accept if + // the remote peer has the higher key (they're the rightful initiator). + if channel.peers.contains_key(&peer_key) { + if *peer_key.as_bytes() > our_key_bytes { + // Remote has higher key — they're the initiator, accept their connection + log_info!("[WEBXDC] Tie-breaker: accepting incoming from {} (higher key wins)", peer_key); + let old = channel.peers.remove(&peer_key).unwrap(); + old.conn.close(0u32.into(), b"tie-break"); + old.read_task.abort(); + old.write_task.abort(); + // Decrement peer count since we'll re-increment below + let _ = channel.peer_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| { + if c > 0 { Some(c - 1) } else { None } + }); + } else { + // We have higher key — we're the initiator, keep our outgoing connection + log_info!("[WEBXDC] Tie-breaker: rejecting incoming from {} (we have higher key)", peer_key); + conn.close(0u32.into(), b"tie-break"); + return Ok(()); + } + } + + // Spawn read/write tasks for this peer + let peer_conn = spawn_peer_tasks( + conn, + peer_key, + topic, + channel.event_target.clone(), + channel.peer_count.clone(), + channels.clone(), + ); + + // Increment peer count and emit events + let new_count = channel.peer_count.fetch_add(1, Ordering::Relaxed) + 1; + log_info!("[WEBXDC] Peer {} joined topic {:?} (now {} peers)", peer_key, topic, new_count); + let peer_str = base32_nopad_encode(peer_key.as_bytes()); + send_event(&channel.event_target, RealtimeEvent::PeerJoined(peer_str)); + + channel.peers.insert(peer_key, peer_conn); + + Ok(()) +} + +/// Connect to a peer: establish QUIC connection, send topic ID, spawn tasks. +/// +/// Tie-breaker: only the side with the higher public key initiates connections. +/// The lower-key side skips `connect_to_peer` and waits for the incoming connection +/// via the accept loop. This prevents simultaneous connections that cause +/// stream orphaning with persistent uni streams. +async fn connect_to_peer( + endpoint: &Endpoint, + addr: EndpointAddr, + topic: TopicId, + event_target: SharedEventTarget, peer_count: SharedPeerCount, - app_handle: Option, - topic_encoded: String, + our_key_bytes: [u8; PUBLIC_KEY_LENGTH], + channels: Arc>>, ) -> Result<()> { - let mut join_tx = Some(join_tx); - log_info!("[WEBXDC] Subscribe loop started for topic {:?}", topic); + // Tie-breaker: only initiate if we have the higher public key + let peer_id_bytes = addr.id.as_bytes(); + if our_key_bytes < *peer_id_bytes { + log_info!("[WEBXDC] Skipping outgoing connection to {} (they have higher key, they'll initiate)", addr.id); + return Ok(()); + } - const TRAILER_LEN: usize = 4 + PUBLIC_KEY_LENGTH; // seq(4) + pubkey(32) + let conn = endpoint.connect(addr.clone(), VECTOR_RT_ALPN).await + .map_err(|e| anyhow!("Failed to connect to peer: {e}"))?; + let peer_key = conn.remote_id(); + + // Open control bi stream and send topic ID (topic handshake) + let (mut ctrl_send, _ctrl_recv) = conn.open_bi().await + .map_err(|e| anyhow!("Failed to open bi stream: {e}"))?; + ctrl_send.write_all(topic.as_bytes()).await + .map_err(|e| anyhow!("Failed to send topic ID: {e}"))?; + + let peer_conn = spawn_peer_tasks( + conn, peer_key, topic, + event_target.clone(), peer_count.clone(), + channels.clone(), + ); + + // Add to channel + let mut channels_guard = channels.write().await; + if let Some(channel) = channels_guard.get_mut(&topic) { + // Should not have an existing connection (we're the initiator), but handle gracefully + if let Some(old) = channel.peers.remove(&peer_key) { + old.conn.close(0u32.into(), b"replaced"); + old.read_task.abort(); + old.write_task.abort(); + } - while let Some(event) = receiver.next().await { - match event { - Ok(Event::Received(msg)) => { - let content = &msg.content; + let new_count = channel.peer_count.fetch_add(1, Ordering::Relaxed) + 1; + log_info!("[WEBXDC] Connected to peer {} for topic {:?} (now {} peers)", peer_key, topic, new_count); + let peer_str = base32_nopad_encode(peer_key.as_bytes()); + send_event(&channel.event_target, RealtimeEvent::PeerJoined(peer_str)); - // Extract trailer (seq + pubkey) via zero-copy slicing - if content.len() >= TRAILER_LEN { - let payload_len = content.len() - TRAILER_LEN; - let sender_key = &content[payload_len + 4..]; + channel.peers.insert(peer_key, peer_conn); + } else { + bail!("Channel removed before peer connection completed"); + } - // Skip messages from ourselves (32-byte memcmp, no PublicKey construction) - if sender_key == our_key_bytes { - continue; - } + Ok(()) +} - // Only encode the payload portion (excludes 36-byte trailer) - send_event(&shared_event_target, RealtimeEvent::Data(base91_encode(&content[..payload_len]))); - } else { - // Malformed message (no trailer) — forward as-is - send_event(&shared_event_target, RealtimeEvent::Data(base91_encode(content))); - } - } - Ok(Event::NeighborUp(peer_id)) => { - // Increment peer count - let new_count = peer_count.fetch_add(1, Ordering::Relaxed) + 1; +/// Spawn read and write tasks for a peer connection. +fn spawn_peer_tasks( + conn: iroh::endpoint::Connection, + peer_key: PublicKey, + topic: TopicId, + event_target: SharedEventTarget, + peer_count: SharedPeerCount, + channels: Arc>>, +) -> PeerConnection { + let (send_tx, send_rx) = tokio::sync::mpsc::channel::>(PEER_SEND_QUEUE_CAPACITY); + + let read_conn = conn.clone(); + let read_target = event_target; + let read_peer_count = peer_count; + let read_channels = channels; + let read_task = tokio::spawn(async move { + run_peer_reader(read_conn, peer_key, topic, read_target, read_peer_count, read_channels).await; + }); + + let write_conn = conn.clone(); + let write_task = tokio::spawn(async move { + run_peer_writer(write_conn, send_rx).await; + }); + + PeerConnection { + conn, + read_task, + write_task, + send_tx, + } +} - // Emit status update to main window - emit_realtime_status(&app_handle, &topic_encoded, new_count, true); +// ─── Per-peer reader/writer tasks ─────────────────────────────────────────── - // Signal that we're connected when first neighbor comes up - if let Some(tx) = join_tx.take() { - let _ = tx.send(()); - send_event(&shared_event_target, RealtimeEvent::Connected); - } - let peer_str = base32_nopad_encode(peer_id.as_bytes()); - send_event(&shared_event_target, RealtimeEvent::PeerJoined(peer_str)); +/// Read varint-length-prefixed messages from a persistent uni stream and deliver to the event target. +/// No trailer needed — with raw QUIC, sender identity comes from the connection +/// itself and QUIC guarantees no duplicates. +async fn run_peer_reader( + conn: iroh::endpoint::Connection, + peer_key: PublicKey, + topic: TopicId, + shared_event_target: SharedEventTarget, + peer_count: SharedPeerCount, + channels: Arc>>, +) { + // Accept the persistent uni stream from this peer's writer + let mut recv = match conn.accept_uni().await { + Ok(r) => r, + Err(_) => { + cleanup_peer(peer_key, &peer_count, &shared_event_target, &channels, &topic).await; + return; + } + }; + + let mut b0 = [0u8; 1]; + loop { + // Read varint length prefix: + // 0xxxxxxx = 1 byte (0–127) + // 10xxxxxx xxxxxxxx = 2 bytes (128–16,383) + // 11xxxxxx xxxxxxxx xxxxxxxx = 3 bytes (16,384–4,194,303) + match recv.read_exact(&mut b0).await { + Ok(()) => {} + Err(_) => break, // Stream/connection closed + } + let msg_len = if b0[0] & 0x80 == 0 { + b0[0] as usize + } else { + let mut extra = [0u8; 1]; + if recv.read_exact(&mut extra).await.is_err() { break; } + if b0[0] & 0xC0 == 0x80 { + ((b0[0] as usize & 0x3F) << 8) | extra[0] as usize + } else { + let mut extra2 = [0u8; 1]; + if recv.read_exact(&mut extra2).await.is_err() { break; } + ((b0[0] as usize & 0x3F) << 16) | ((extra[0] as usize) << 8) | extra2[0] as usize } - Ok(Event::NeighborDown(peer_id)) => { - let peer_str = base32_nopad_encode(peer_id.as_bytes()); + }; + if msg_len == 0 { + continue; // Stream announcement packet — skip + } + if msg_len > MAX_MESSAGE_SIZE { + log_warn!("[WEBXDC] Peer {} sent oversized message: {} bytes", peer_key, msg_len); + break; + } + + // Read the message body + let mut content = vec![0u8; msg_len]; + match recv.read_exact(&mut content).await { + Ok(()) => {} + Err(e) => { + log_warn!("[WEBXDC] Failed to read message from peer {}: {e}", peer_key); + break; + } + } + + // Deliver raw payload — no trailer to strip, no self-check needed + send_event(&shared_event_target, RealtimeEvent::Data(base91_encode(&content))); + } + + cleanup_peer(peer_key, &peer_count, &shared_event_target, &channels, &topic).await; +} + +/// Clean up after a peer disconnects. +async fn cleanup_peer( + peer_key: PublicKey, + peer_count: &SharedPeerCount, + shared_event_target: &SharedEventTarget, + channels: &Arc>>, + topic: &TopicId, +) { + log_info!("[WEBXDC] Peer disconnected: {}", peer_key); + let peer_str = base32_nopad_encode(peer_key.as_bytes()); + + // Decrement peer count (saturating to avoid underflow) + let _ = peer_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| { + if c > 0 { Some(c - 1) } else { None } + }); + + send_event(shared_event_target, RealtimeEvent::PeerLeft(peer_str)); + + // Remove from channel peers map + let mut channels_guard = channels.write().await; + if let Some(channel) = channels_guard.get_mut(topic) { + if let Some(peer) = channel.peers.remove(&peer_key) { + peer.write_task.abort(); + } + } +} - // Atomically decrement peer count (saturating to avoid underflow) - let _ = peer_count.fetch_update( - Ordering::Relaxed, - Ordering::Relaxed, - |count| if count > 0 { Some(count - 1) } else { None }, - ); - let new_count = peer_count.load(Ordering::Relaxed); +/// Write varint-length-prefixed messages to a persistent uni stream. +/// Opens one stream and reuses it for all messages — eliminates per-message +/// stream handshake overhead (critical for relay-routed connections). +async fn run_peer_writer( + conn: iroh::endpoint::Connection, + mut rx: tokio::sync::mpsc::Receiver>, +) { + // Open the persistent send stream + let mut send = match conn.open_uni().await { + Ok(s) => s, + Err(e) => { + log_warn!("[WEBXDC] Failed to open send stream: {e}"); + return; + } + }; - // Emit status update to main window - emit_realtime_status(&app_handle, &topic_encoded, new_count, true); + // Write a zero-length announcement to make the stream visible to the remote reader. + // QUIC doesn't notify the peer that a stream exists until data is written. + if let Err(e) = send.write_all(&[0u8]).await { + log_warn!("[WEBXDC] Failed to announce stream: {e}"); + return; + } - send_event(&shared_event_target, RealtimeEvent::PeerLeft(peer_str)); + while let Some(data) = rx.recv().await { + // Varint length prefix: 1 byte (0–127), 2 bytes (128–16,383), 3 bytes (16,384+) + let n = data.len(); + let (hdr, hdr_len) = if n < 128 { + ([n as u8, 0, 0], 1) + } else if n < 16384 { + ([0x80 | (n >> 8) as u8, n as u8, 0], 2) + } else { + ([0xC0 | (n >> 16) as u8, (n >> 8) as u8, n as u8], 3) + }; + if let Err(e) = send.write_all(&hdr[..hdr_len]).await { + log_warn!("[WEBXDC] Peer write failed (length): {e}"); + break; + } + if let Err(e) = send.write_all(&data).await { + log_warn!("[WEBXDC] Peer write failed (payload): {e}"); + break; + } + } + + // Gracefully close the stream + let _ = send.finish(); +} + +// ─── Drainer ──────────────────────────────────────────────────────────────── + +/// Drainer task: reads from the global send queue and fans out to all per-peer +/// send queues. Each peer has its own independent queue so one slow peer +/// cannot block others — the architectural solution to head-of-line blocking. +async fn run_send_drainer( + mut rx: tokio::sync::mpsc::Receiver>, + channels: Arc>>, + topic: TopicId, + drops: Arc, +) { + let mut drop_check = tokio::time::interval(std::time::Duration::from_secs(5)); + drop_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut batch = Vec::with_capacity(64); + + loop { + // Wait for at least one message (or drop-check tick) + tokio::select! { + msg = rx.recv() => { + match msg { + Some(data) => batch.push(data), + None => break, + } } - Ok(Event::Lagged) => { - log_warn!("[WEBXDC] Gossip lagged for topic {:?}", topic); - send_event(&shared_event_target, RealtimeEvent::Lagged); + _ = drop_check.tick() => { + let dropped = drops.swap(0, Ordering::Relaxed); + if dropped > 0 { + log_warn!("[WEBXDC] Realtime overload: {dropped} packets dropped in last 5s"); + } + continue; } - Err(e) => { - log_error!("[WEBXDC] Gossip error for topic {:?}: {e}", topic); + } + + // Drain all immediately-available messages (non-blocking) + while batch.len() < 64 { + match rx.try_recv() { + Ok(data) => batch.push(data), + Err(_) => break, } } - } - log_info!("[WEBXDC] Subscribe loop ended for topic {:?}", topic); - Ok(()) + // Fan-out: send each message to every peer's individual queue (non-blocking) + let channels_guard = channels.read().await; + if let Some(channel) = channels_guard.get(&topic) { + for data in batch.drain(..) { + for (_peer_key, peer_conn) in &channel.peers { + // Non-blocking: if this peer's queue is full, they're slow — skip them + let _ = peer_conn.send_tx.try_send(data.clone()); + } + } + } else { + batch.clear(); + } + } } +// ─── RealtimeManager ──────────────────────────────────────────────────────── + /// Global Iroh state manager /// /// Uses `OnceCell` for lock-free reads after initialization — @@ -613,6 +975,8 @@ pub struct RealtimeManager { iroh: tokio::sync::OnceCell, /// Custom relay URL (if any) relay_url: Option, + /// WebSocket server info (port + token), set once after Iroh init + ws_info: std::sync::OnceLock, } impl RealtimeManager { @@ -620,15 +984,41 @@ impl RealtimeManager { Self { iroh: tokio::sync::OnceCell::new(), relay_url, + ws_info: std::sync::OnceLock::new(), } } /// Get or initialize the Iroh state. /// After first call, this is a single atomic load (~5ns). + /// Also starts the realtime WebSocket server on first init. pub async fn get_or_init(&self) -> Result<&IrohState> { - self.iroh + let iroh = self.iroh .get_or_try_init(|| IrohState::new(self.relay_url.clone())) - .await + .await?; + + // Start the WS server once (after IrohState is available) + if self.ws_info.get().is_none() { + match super::rt_ws::start( + iroh.send_handles.clone(), + ).await { + Ok(info) => { + let _ = self.ws_info.set(info); + } + Err(e) => { + log_warn!("[WEBXDC] Failed to start RT WS server: {e}"); + } + } + } + + Ok(iroh) + } + + /// Get the WebSocket URL for the realtime fast path, if the server is running. + /// Returns `ws://127.0.0.1:{port}/{token}`. + pub fn ws_url(&self) -> Option { + self.ws_info.get().map(|info| { + format!("ws://127.0.0.1:{}/{}", info.port, info.token) + }) } /// Shutdown Iroh if initialized @@ -646,7 +1036,8 @@ impl Default for RealtimeManager { } } -/// Generate a new random topic ID for a Mini App +// ─── Topic ID helpers ─────────────────────────────────────────────────────── + /// Generate a random topic ID (for testing/fallback only) #[allow(dead_code)] pub fn generate_topic_id() -> TopicId { @@ -661,7 +1052,7 @@ pub fn generate_topic_id() -> TopicId { /// Including message_id ensures reposts create isolated instances. pub fn derive_topic_id(file_hash: &str, chat_id: &str, message_id: &str) -> TopicId { use sha2::{Sha256, Digest}; - + let mut hasher = Sha256::new(); hasher.update(b"webxdc-realtime-v1:"); hasher.update(file_hash.as_bytes()); @@ -669,7 +1060,7 @@ pub fn derive_topic_id(file_hash: &str, chat_id: &str, message_id: &str) -> Topi hasher.update(chat_id.as_bytes()); hasher.update(b":"); hasher.update(message_id.as_bytes()); - + let result = hasher.finalize(); let mut bytes = [0u8; 32]; bytes.copy_from_slice(&result); @@ -769,4 +1160,4 @@ mod tests { assert!(!is_lan_addr(&"2001:db8::1".parse::().unwrap())); assert!(!is_lan_addr(&"2607:f8b0:4004:800::200e".parse::().unwrap())); } -} \ No newline at end of file +} diff --git a/src-tauri/src/miniapps/rt_ws.rs b/src-tauri/src/miniapps/rt_ws.rs new file mode 100644 index 00000000..40186d96 --- /dev/null +++ b/src-tauri/src/miniapps/rt_ws.rs @@ -0,0 +1,241 @@ +//! Lightweight localhost WebSocket server for zero-overhead realtime sends. +//! +//! Mini App JS connects via `ws://127.0.0.1:{port}/{token}/{label}` and sends +//! raw binary frames — one syscall per message, no HTTP framing, no WebView IPC +//! bottleneck. This bypasses the WKWebView/WebView2 custom scheme pipeline that +//! limits `fetch()` to ~100 req/s. + +use std::collections::HashMap; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +use super::realtime::SendHandle; + +/// Info returned after starting the WS server. +pub(crate) struct WsInfo { + pub port: u16, + pub token: String, +} + +/// Shared state passed to each WS connection handler. +struct WsState { + token: String, + send_handles: Arc>>, +} + +/// Start the realtime WebSocket server on a random localhost port. +/// +/// Returns `(port, token)`. The server runs as a background tokio task. +pub(crate) async fn start( + send_handles: Arc>>, +) -> Result { + // Random 32-char hex token (128-bit security) + let token = { + let mut bytes = [0u8; 16]; + use rand::RngCore; + rand::rngs::OsRng.fill_bytes(&mut bytes); + crate::simd::hex::bytes_to_hex_16(&bytes) + }; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind RT WS server: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to get local addr: {e}"))? + .port(); + + log_info!("[WEBXDC] Realtime WS server listening on 127.0.0.1:{port}"); + + let state = Arc::new(WsState { + token: token.clone(), + send_handles, + }); + + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let st = Arc::clone(&state); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, &st).await { + log_trace!("[WEBXDC] RT WS connection error: {e}"); + } + }); + } + Err(e) => { + log_warn!("[WEBXDC] RT WS accept error: {e}"); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + } + }); + + Ok(WsInfo { port, token }) +} + +/// Handle a single WebSocket connection. +/// +/// The URL path is `/{token}/{percent_encoded_label}`. +/// After the upgrade handshake, binary frames are forwarded to `fast_send()`. +async fn handle_connection( + stream: tokio::net::TcpStream, + state: &WsState, +) -> Result<(), Box> { + // Extract label from the URL during the WebSocket handshake + let label: Arc> = Arc::new(std::sync::OnceLock::new()); + let label_for_cb = label.clone(); + let token_ref = state.token.clone(); + + let ws_stream = tokio_tungstenite::accept_hdr_async( + stream, + move |req: &tokio_tungstenite::tungstenite::handshake::server::Request, + resp: tokio_tungstenite::tungstenite::handshake::server::Response| + -> Result< + tokio_tungstenite::tungstenite::handshake::server::Response, + tokio_tungstenite::tungstenite::handshake::server::ErrorResponse, + > { + let path = req.uri().path(); + // Parse /{token}/{percent_encoded_label} + let trimmed = path.trim_start_matches('/'); + let (req_token, rest) = match trimmed.split_once('/') { + Some(pair) => pair, + None => { + return Err(http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(None) + .unwrap()); + } + }; + if req_token != token_ref { + return Err(http::Response::builder() + .status(http::StatusCode::FORBIDDEN) + .body(None) + .unwrap()); + } + // Percent-decode the label (window labels contain colons) + let decoded = percent_decode(rest); + let _ = label_for_cb.set(decoded); + Ok(resp) + }, + ) + .await?; + + let label = match label.get() { + Some(l) => l.clone(), + None => return Ok(()), // handshake rejected + }; + + log_info!("[WEBXDC] RT WS connected: {label}"); + + // Read loop: binary frames → fast_send, with periodic stats + let (mut _sink, mut stream) = ws_stream.split(); + let mut msg_count: u64 = 0; + let mut total_nanos: u64 = 0; + let mut peak_nanos: u64 = 0; + let mut total_bytes: u64 = 0; + let mut stats_interval = tokio::time::interval(std::time::Duration::from_secs(5)); + stats_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Skip the first immediate tick + stats_interval.tick().await; + + loop { + tokio::select! { + msg = stream.next() => { + match msg { + Some(Ok(Message::Binary(data))) => { + let len = data.len(); + if len <= 128_000 { + let t0 = std::time::Instant::now(); + fast_send_inline( + &state.send_handles, + &label, + data.into(), + ); + let elapsed = t0.elapsed().as_nanos() as u64; + msg_count += 1; + total_nanos += elapsed; + total_bytes += len as u64; + if elapsed > peak_nanos { peak_nanos = elapsed; } + } + } + Some(Ok(Message::Ping(payload))) => { + let _ = _sink.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + _ = stats_interval.tick() => { + if msg_count > 0 { + let avg_us = (total_nanos / msg_count) / 1_000; + let peak_us = peak_nanos / 1_000; + let avg_kb = total_bytes / msg_count / 1024; + log_info!( + "[WEBXDC] RT WS stats: {msg_count} msgs in 5s ({}/s), avg {avg_us}μs, peak {peak_us}μs, avg {avg_kb}KB/msg", + msg_count / 5 + ); + msg_count = 0; + total_nanos = 0; + peak_nanos = 0; + total_bytes = 0; + } + } + } + } + + log_info!("[WEBXDC] RT WS disconnected: {label}"); + Ok(()) +} + +/// Inline fast_send — forwards raw payload directly to the send queue. +/// No trailer needed: raw QUIC connections identify senders by connection +/// and guarantee no duplicates, so seq/pubkey trailers are unnecessary. +#[inline] +fn fast_send_inline( + send_handles: &std::sync::RwLock>, + label: &str, + data: Vec, +) { + let handles = send_handles.read().unwrap_or_else(|e| e.into_inner()); + let Some(handle) = handles.get(label) else { + return; + }; + + // Non-blocking enqueue — drops packet on overload + if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = handle.send_tx.try_send(data) { + handle.drops.fetch_add(1, Ordering::Relaxed); + } +} + +/// Simple percent-decode for URL path segments. +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut result = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + result.push((hi << 4) | lo); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8(result).unwrap_or_else(|_| input.to_string()) +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} diff --git a/src-tauri/src/miniapps/scheme.rs b/src-tauri/src/miniapps/scheme.rs index fd485032..8e408385 100644 --- a/src-tauri/src/miniapps/scheme.rs +++ b/src-tauri/src/miniapps/scheme.rs @@ -62,7 +62,8 @@ static CSP: LazyLock = LazyLock::new(|| { ]), ); - // Restrict connections to self, IPC, and data/blob URLs only + // Restrict connections to self, IPC, data/blob URLs, and localhost WebSocket + // (the realtime WS server uses a random token for auth, so wildcard port is safe) m.insert( "connect-src".to_string(), CspDirectiveSources::List(vec![ @@ -70,6 +71,7 @@ static CSP: LazyLock = LazyLock::new(|| { "ipc:".to_owned(), "data:".to_owned(), "blob:".to_owned(), + "ws://127.0.0.1:*".to_owned(), ]), ); @@ -282,7 +284,7 @@ pub fn miniapp_protocol( // Windows/Android: http://webxdc.localhost/ let webview_label = ctx.webview_label().to_owned(); - + // Security: Only allow Mini App windows to access this scheme if !webview_label.starts_with("miniapp:") { log_error!( @@ -293,7 +295,7 @@ pub fn miniapp_protocol( } let app_handle = ctx.app_handle().clone(); - + // Spawn an async task to handle the request without blocking // This is the pattern used by DeltaChat to avoid deadlocks on Windows tauri::async_runtime::spawn(async move { @@ -485,6 +487,8 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String var realtimeChannel = null; var realtimeListener = null; var tauriChannel = null; + var rtWs = null; // WebSocket for realtime fast-path + var rtWsFailed = false; // true if WS can't connect (e.g. Linux/WebKitGTK) function waitForTauri(callback) {{ if (window.__TAURI__ && window.__TAURI__.core) {{ @@ -548,19 +552,22 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String send: function(data) {{ if (realtimeChannel === null) return; var buf = data instanceof Uint8Array ? data : new Uint8Array(data); - var encoded = b91e(buf); - waitForTauri(function() {{ + // Fast path: WebSocket binary frame (persistent TCP, ~1μs per msg) + if (rtWs && rtWs.readyState === 1) {{ + rtWs.send(buf); + }} else if (rtWsFailed && window.__TAURI__) {{ + // Fallback: Tauri invoke (for Linux/WebKitGTK where WS from + // custom scheme origins silently hangs) window.__TAURI__.core.invoke('miniapp_send_realtime_data', {{ - data: encoded - }}).catch(function(err) {{ - console.error('[webxdc] Failed to send realtime data:', err); + data: Array.from(buf) }}); - }}); + }} }}, leave: function() {{ realtimeListener = null; realtimeChannel = null; + if (rtWs) {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }} waitForTauri(function() {{ window.__TAURI__.core.invoke('miniapp_leave_realtime_channel', {{}}).catch(function(err) {{ console.error('[webxdc] Failed to leave realtime channel:', err); @@ -578,6 +585,29 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String }}; window.__TAURI__.core.invoke('miniapp_join_realtime_channel', {{ channel: tauriChannel + }}).then(function(result) {{ + // Open WebSocket fast-path if backend returned a URL + if (result && result.ws_url) {{ + try {{ + var label = encodeURIComponent(window.__TAURI_INTERNALS__.metadata.currentWebview.label || ''); + rtWs = new WebSocket(result.ws_url + '/' + label); + rtWs.binaryType = 'arraybuffer'; + rtWs.onclose = function() {{ rtWs = null; }}; + rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; rtWsFailed = true; }}; + // Detect WebKitGTK silent WS block: if still CONNECTING after 1.5s, fall back to invoke + setTimeout(function() {{ + if (rtWs && rtWs.readyState === 0) {{ + console.warn('[webxdc] WebSocket stuck in CONNECTING — falling back to invoke'); + try {{ rtWs.close(); }} catch(e) {{}} + rtWs = null; + rtWsFailed = true; + }} + }}, 1500); + }} catch(e) {{ + rtWs = null; + rtWsFailed = true; + }} + }} }}).catch(function(err) {{ console.error('[webxdc] Failed to join realtime channel:', err); }}); @@ -598,7 +628,7 @@ fn make_success_response(body: Vec, content_type: &str, granted_permissions: http::Response::builder() .status(http::StatusCode::OK) .header(http::header::CONTENT_TYPE, content_type) - .header(http::header::CONTENT_SECURITY_POLICY, CSP.as_str()) + .header(http::header::CONTENT_SECURITY_POLICY, &*CSP) // Ensure that the browser doesn't try to interpret the file incorrectly .header(http::header::X_CONTENT_TYPE_OPTIONS, "nosniff") // Dynamic permissions policy based on user grants @@ -625,7 +655,7 @@ fn make_error_response(status: http::StatusCode, message: &str, granted_permissi http::Response::builder() .status(status) .header(http::header::CONTENT_TYPE, "text/plain") - .header(http::header::CONTENT_SECURITY_POLICY, CSP.as_str()) + .header(http::header::CONTENT_SECURITY_POLICY, &*CSP) .header(http::header::X_CONTENT_TYPE_OPTIONS, "nosniff") .header("Permissions-Policy", permissions_policy) // Cross-origin isolation headers for SharedArrayBuffer (WASM threads) diff --git a/src-tauri/src/miniapps/state.rs b/src-tauri/src/miniapps/state.rs index 8dc80602..1827d9db 100644 --- a/src-tauri/src/miniapps/state.rs +++ b/src-tauri/src/miniapps/state.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; use serde::{Deserialize, Serialize}; -use iroh_gossip::proto::TopicId; +use super::realtime::TopicId; use super::error::Error; use super::realtime::RealtimeManager; From ededab30ad58d0899c63f57cd255f638e5bde374 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Thu, 19 Mar 2026 01:20:15 +0000 Subject: [PATCH 002/417] feat: stabilize gossip realtime channels with preconnect, SQLite persistence, and message buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major reliability and performance improvements to Mini App P2P realtime channels. Preconnect System: - Scan XDC zip for `joinRealtimeChannel` usage when opening a Mini App - If detected, pre-initialize Iroh endpoint + relay connection in background - Create gossip channel with message buffering (no data dropped before JS listener attaches) - Send peer advertisement via Nostr immediately so peers discover us early - Connect to known peers from SQLite with 5s timeout (stale peers fail fast) - Watch signal coordinates preconnect with joinRealtimeChannel (no polling, no races) - joinRealtimeChannel awaits signal then re-joins to set event target + flush buffer - Eliminates 2-5s Iroh init delay from user-perceived join time SQLite Peer Persistence: - Peer advertisements (Kind 30078) saved to events table with reference_id = topic - Peer-left signals invalidate older advertisements from same npub - On game open, query SQLite for valid (non-left-invalidated) advertisements - Survives app restarts, offline→online transitions, late joiners Gossip Transport (iroh-gossip 0.97): - Proper leave_channel cleanup: explicit drop of GossipSender before aborting subscribe loop - join_peers on NeighborUp for bidirectional topic association - NeighborDown auto-reconnect via Nostr re-advertisement after 2s delay - Malformed messages (<36-byte trailer) dropped with warning - BBR congestion control enabled via noq-proto for better relay throughput - EventTargetState with message buffer: incoming data buffered when target is None, flushed on set - Read lock on hot path for send_event (write lock only when buffering) WS Fast-Path on Android: - Moved send_handles from IrohState to RealtimeManager - ensure_ws_started() with std::net::TcpListener sync bind for Android JNI timing - Android JNI awaits preconnect signal, skips redundant peer connections when preconnect ran - 1500ms stuck-CONNECTING timeout matching desktop behavior Desktop Window Close Handler: - Full teardown: leave_channel, remove session peers, emit status, send peer-left via Nostr - Replaces old partial cleanup that kept stale channels open Cleanup: - Removed debug iroh relay patches (hundreds of warn! log lines) - Removed tracing-subscriber dependency - Removed dead rt_ws::start() function - Removed unused PeerAdvertisementRecord.created_at field - Removed duplicate peer connections (preconnect + joinRealtimeChannel were both connecting) - Fixed irrefutable if-let patterns on to_bech32() calls - CSP: added 'wasm-unsafe-eval' for Chromium 145+ WASM JIT compilation Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 302 +++-- src-tauri/Cargo.toml | 5 +- src-tauri/src/android/miniapp_jni.rs | 192 ++- src-tauri/src/commands/realtime.rs | 74 +- src-tauri/src/db/miniapps.rs | 68 + src-tauri/src/db/mod.rs | 1 + src-tauri/src/lib.rs | 15 - src-tauri/src/miniapps/commands.rs | 398 ++++-- src-tauri/src/miniapps/realtime.rs | 1140 +++++++---------- src-tauri/src/miniapps/rt_ws.rs | 83 +- src-tauri/src/miniapps/scheme.rs | 15 +- src-tauri/src/miniapps/state.rs | 195 +-- src-tauri/src/mls/mod.rs | 8 +- src-tauri/src/rumor.rs | 60 +- src-tauri/src/services/event_handler.rs | 190 ++- src-tauri/src/services/mod.rs | 1 + .../src/services/subscription_handler.rs | 8 +- src/main.js | 105 +- src/styles.css | 4 +- 19 files changed, 1651 insertions(+), 1213 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 66353119..e8f8b18b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -206,19 +206,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-executor" version = "1.13.1" @@ -2003,6 +1990,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2137,6 +2130,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset 0.5.7", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -3388,9 +3394,9 @@ dependencies = [ [[package]] name = "iroh" -version = "0.96.1" +version = "0.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117" +checksum = "feb56e7e4b0ec7fba7efa6a236b016a52b5d927d50244aceb9e20566159b1a32" dependencies = [ "backon", "bytes", @@ -3402,22 +3408,22 @@ dependencies = [ "getrandom 0.3.2", "hickory-resolver", "http", - "igd-next", + "ipnet", "iroh-base", "iroh-metrics", - "iroh-quinn", - "iroh-quinn-proto", - "iroh-quinn-udp", "iroh-relay", "n0-error", "n0-future", "n0-watcher", - "netdev", "netwatch", + "noq", + "noq-proto", + "noq-udp", "papaya", "pin-project", "pkarr", "pkcs8 0.11.0-rc.11", + "portable-atomic", "portmapper", "rand 0.9.2", "reqwest 0.12.24", @@ -3441,9 +3447,9 @@ dependencies = [ [[package]] name = "iroh-base" -version = "0.96.1" +version = "0.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c99d836a1c99e037e98d1bf3ef209c3a4df97555a00ce9510eb78eccdf5567" +checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" dependencies = [ "curve25519-dalek 5.0.0-pre.1", "data-encoding", @@ -3459,6 +3465,36 @@ dependencies = [ "zeroize_derive", ] +[[package]] +name = "iroh-gossip" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db5b64f3cb0a0c8b68b57888acd4cefcd2f0774f1a132d2a498cbb2a92fbc55" +dependencies = [ + "blake3", + "bytes", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek 3.0.0-pre.1", + "futures-concurrency", + "futures-lite", + "futures-util", + "hex", + "indexmap 2.12.0", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.2", + "serde", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "iroh-metrics" version = "0.38.2" @@ -3486,71 +3522,11 @@ dependencies = [ "syn 2.0.100", ] -[[package]] -name = "iroh-quinn" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" -dependencies = [ - "bytes", - "cfg_aliases", - "iroh-quinn-proto", - "iroh-quinn-udp", - "pin-project-lite", - "rustc-hash", - "rustls", - "socket2 0.6.1", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "web-time", -] - -[[package]] -name = "iroh-quinn-proto" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de99ad8adc878ee0e68509ad256152ce23b8bbe45f5539d04e179630aca40a9" -dependencies = [ - "bytes", - "derive_more 2.1.1", - "enum-assoc", - "fastbloom", - "getrandom 0.3.2", - "identity-hash", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "sorted-index-buffer", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "iroh-quinn-udp" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" -dependencies = [ - "cfg_aliases", - "libc", - "socket2 0.6.1", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "iroh-relay" -version = "0.96.1" +version = "0.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" +checksum = "d786b260cadfe82ae0b6a9e372e8c78949096a06c857d1c3521355cefced0f55" dependencies = [ "blake3", "bytes", @@ -3565,11 +3541,11 @@ dependencies = [ "hyper-util", "iroh-base", "iroh-metrics", - "iroh-quinn", - "iroh-quinn-proto", "lru", "n0-error", "n0-future", + "noq", + "noq-proto", "num_enum", "pin-project", "pkarr", @@ -3593,6 +3569,33 @@ dependencies = [ "z32", ] +[[package]] +name = "irpc" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f47b7c52662d673df377b5ac40c121c7ff56eb764e520fae6543686132f7957" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c1a4b460634aeed6dc01236a0047867de70e30562d91a0ad031dcb3ac33fb4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -4413,6 +4416,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" dependencies = [ + "anyhow", "n0-error-macros", "spez", ] @@ -4515,9 +4519,9 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "netdev" -version = "0.40.0" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc9815643a243856e7bd84524e1ff739e901e846cfb06ad9627cd2b6d59bd737" +checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" dependencies = [ "block2 0.6.1", "dispatch2", @@ -4526,7 +4530,7 @@ dependencies = [ "libc", "mac-addr", "netlink-packet-core", - "netlink-packet-route 0.25.1", + "netlink-packet-route", "netlink-sys", "objc2-core-foundation", "objc2-system-configuration", @@ -4546,21 +4550,9 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" -dependencies = [ - "bitflags 2.9.0", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-packet-route" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" dependencies = [ "bitflags 2.9.0", "libc", @@ -4597,15 +4589,14 @@ dependencies = [ [[package]] name = "netwatch" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" +checksum = "3b1b27babe89ef9f2237bc6c028bea24fa84163a1b6f8f17ff93573ebd7d861f" dependencies = [ "atomic-waker", "bytes", "cfg_aliases", "derive_more 2.1.1", - "iroh-quinn-udp", "js-sys", "libc", "n0-error", @@ -4613,9 +4604,10 @@ dependencies = [ "n0-watcher", "netdev", "netlink-packet-core", - "netlink-packet-route 0.28.0", + "netlink-packet-route", "netlink-proto", "netlink-sys", + "noq-udp", "objc2-core-foundation", "objc2-system-configuration", "pin-project-lite", @@ -4666,6 +4658,67 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "noq" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf" +dependencies = [ + "bytes", + "cfg_aliases", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "fastbloom", + "getrandom 0.3.2", + "identity-hash", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.1", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nostr" version = "0.44.2" @@ -5710,7 +5763,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap 2.12.0", ] @@ -5905,29 +5958,17 @@ dependencies = [ name = "pkarr" version = "5.0.3" dependencies = [ - "async-compat", "base32", "bytes", "cfg_aliases", "document-features", - "dyn-clone", "ed25519-dalek 3.0.0-pre.1", - "futures-buffered", - "futures-lite", "getrandom 0.4.1", - "log", - "lru", "ntimestamp", - "reqwest 0.13.1", "self_cell", "serde", - "sha1_smol", "simple-dns", "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "wasm-bindgen-futures", ] [[package]] @@ -6041,9 +6082,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portmapper" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d2a8825353ace3285138da3378b1e21860d60351942f7aa3b99b13b41f80318" +checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" dependencies = [ "base64 0.22.1", "bytes", @@ -7262,12 +7303,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.9" @@ -7549,18 +7584,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -9174,7 +9209,7 @@ dependencies = [ "http", "image", "iroh", - "iroh-quinn-proto", + "iroh-gossip", "jni", "lofty", "mdk-core", @@ -9182,6 +9217,7 @@ dependencies = [ "mdk-storage-traits", "memchr", "ndk-context", + "noq-proto", "nostr-blossom", "nostr-sdk", "openmls_rust_crypto", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d298c1ed..efc4b28f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,8 +78,9 @@ http = "1.3" url = "2.5.7" # Realtime peer channels (Iroh P2P) -iroh = "0.96" -iroh-quinn-proto = "0.15" +iroh = "0.97" +iroh-gossip = "0.97" +noq-proto = "0.16" rand = "0.8" tokio-tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } diff --git a/src-tauri/src/android/miniapp_jni.rs b/src-tauri/src/android/miniapp_jni.rs index 8795e0d1..5e99c92e 100644 --- a/src-tauri/src/android/miniapp_jni.rs +++ b/src-tauri/src/android/miniapp_jni.rs @@ -9,6 +9,7 @@ use jni::sys::{jint, jstring, jobject}; use jni::JNIEnv; use std::io::Read; use tauri::{Emitter, Manager}; +use nostr_sdk::prelude::ToBech32; use crate::util::bytes_to_hex_string; use crate::TAURI_APP; @@ -128,15 +129,34 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppManager_onMiniAppClosed( 0 }; - // Emit status update to main window so frontend clears "Playing" state + // Remove ourselves from session peers + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + let my_npub = my_pk.to_bech32().unwrap(); + state.remove_session_peer(&channel.topic, &my_npub).await; + } + + // Emit status update — session_peers is the single source of truth + let session_peers = state.get_session_peers(&channel.topic).await; + let session_count = session_peers.len(); if let Some(main_window) = app.get_webview_window("main") { let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ "topic": topic_encoded, - "peer_count": peer_count, + "peer_count": session_count, + "peers": session_peers, "is_active": false, - "has_pending_peers": peer_count > 0, + "has_pending_peers": session_count > 0, })); - log_info!("[WEBXDC] Emitted miniapp_realtime_status: active=false, peer_count={} for topic {}", peer_count, topic_encoded); + log_info!("[WEBXDC] Emitted miniapp_realtime_status: active=false, peer_count={} for topic {}", session_count, topic_encoded); + } + // Send peer-left signal so other clients update their online indicators + if let Some(instance) = state.get_instance(&miniapp_id_owned).await { + let chat_id = instance.chat_id.clone(); + let topic_for_left = topic_encoded.clone(); + tokio::spawn(async move { + if !crate::commands::realtime::send_webxdc_peer_left(chat_id, topic_for_left).await { + log_warn!("[WEBXDC] Failed to send peer-left signal"); + } + }); } } @@ -372,14 +392,15 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative active: true, }).await; - // Eagerly init Iroh + WS server so we can return the WS URL - let ws_url = match state.realtime.get_or_init().await { - Ok(_) => state.realtime.ws_url(), - Err(e) => { - log_warn!("[WEBXDC] Android: Failed to eagerly init Iroh: {}", e); - None - } - }; + // NOTE: Do NOT call get_or_init() here! This block runs on a temporary + // single-threaded tokio runtime (rt) that is dropped after block_on(). + // Any tasks spawned by Endpoint::bind() would be killed when rt is dropped. + // Iroh init happens in the tauri::async_runtime::spawn block below instead. + // + // BUT: start the WS server eagerly (sync bind + spawn on main runtime). + // This ensures ws_url is available BEFORE returning to JS. + state.realtime.ensure_ws_started(); + let ws_url = state.realtime.ws_url(); Ok::<_, String>((Some(instance), topic, topic_encoded, ws_url)) }); @@ -417,7 +438,15 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative tauri::async_runtime::spawn(async move { let state = app_for_join.state::(); - // Initialize Iroh + // Wait for preconnect to finish (if it ran for this Mini App) + if let Some(mut rx) = state.take_preconnect_signal(&miniapp_id_for_join).await { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + rx.wait_for(|ready| *ready), + ).await; + } + + // Initialize Iroh (instant if preconnect already ran) let iroh = match state.realtime.get_or_init().await { Ok(iroh) => iroh, Err(e) => { @@ -430,37 +459,38 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative // Join the channel with mpsc event target let event_target = crate::miniapps::realtime::EventTarget::MpscSender(tx); - match iroh.join_channel(topic, vec![], event_target, Some(app_for_join.clone()), miniapp_id_for_join.clone()).await { - Ok((is_rejoin, _)) => { - if is_rejoin { + let is_rejoin = match iroh.join_channel(topic, vec![], Some(event_target), Some(app_for_join.clone()), miniapp_id_for_join.clone()).await { + Ok((rejoin, _)) => { + if rejoin { log_info!("[WEBXDC] Android: Re-joined existing channel for topic: {}", topic_encoded_for_join); } else { log_info!("[WEBXDC] Android: Joined new channel for topic: {}", topic_encoded_for_join); } + rejoin } Err(e) => { log_error!("[WEBXDC] Android: Failed to join channel: {}", e); - // Clean up the channel state we set synchronously state.remove_realtime_channel(&miniapp_id_for_join).await; return; } - } + }; - // Process pending peers - let pending_peers = state.take_pending_peers(&topic).await; - let pending_peer_count = pending_peers.len(); - if !pending_peers.is_empty() { - log_info!("[WEBXDC] Android: Adding {} pending peers", pending_peer_count); - for pending in pending_peers { - let peer_id = pending.node_addr.id; - if let Err(e) = iroh.add_peer(topic, pending.node_addr).await { - log_warn!("[WEBXDC] Android: Failed to add pending peer {}: {}", peer_id, e); + // Only connect peers + send advertisement if preconnect didn't + if !is_rejoin { + let cached_addrs = state.take_peer_addrs(&topic).await; + if !cached_addrs.is_empty() { + log_info!("[WEBXDC] Android: Connecting to {} cached peers", cached_addrs.len()); + for addr in cached_addrs { + let peer_id = addr.id; + if let Err(e) = iroh.add_peer(topic, addr).await { + log_warn!("[WEBXDC] Android: Failed to connect to cached peer {}: {}", peer_id, e); + } } } } // Get node address and send peer advertisements - { + if !is_rejoin { let node_addr = iroh.get_node_addr(); match crate::miniapps::realtime::encode_node_addr(&node_addr) { Ok(node_addr_encoded) => { @@ -493,15 +523,22 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative log_warn!("[WEBXDC] Android: Failed to encode node addr: {}", e); } } + } // if !is_rejoin (advertisement) + + // Add ourselves to session peers + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + let my_npub = my_pk.to_bech32().unwrap(); + state.add_session_peer(topic, my_npub).await; } - // Emit status event to main window so UI updates to show "Playing" + // Emit status event — session_peers is the single source of truth if let Some(main_window) = app_for_join.get_webview_window("main") { - let current_peer_count = iroh.get_peer_count(&topic).await; - let effective_peer_count = std::cmp::max(current_peer_count, pending_peer_count); + let session_peers = state.get_session_peers(&topic).await; + let peer_count = session_peers.len(); let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ "topic": topic_encoded_for_join, - "peer_count": effective_peer_count, + "peer_count": peer_count, + "peers": session_peers, "is_active": true, })); } @@ -566,6 +603,78 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_leaveRealtimeChannelNativ }); } +/// Send realtime data via gossip (JNI bridge fallback when WS isn't available). +#[no_mangle] +pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_sendRealtimeDataNative( + mut env: JNIEnv, + _class: JClass, + miniapp_id: JString, + data: JString, +) { + let miniapp_id: String = match env.get_string(&miniapp_id) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to get miniapp_id: {:?}", e); + return; + } + }; + + let encoded: String = match env.get_string(&data) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to get data string: {:?}", e); + return; + } + }; + + // Decode base91 to raw bytes + let bytes = match fast_thumbhash::base91_decode(&encoded) { + Ok(b) => b, + Err(_) => { + log_error!("[{}] sendRealtimeData: failed to decode base91", miniapp_id); + return; + } + }; + + if bytes.len() > 128_000 { + log_error!("[{}] sendRealtimeData: data too large ({} bytes)", miniapp_id, bytes.len()); + return; + } + + let app = match TAURI_APP.get() { + Some(a) => a.clone(), + None => { + log_error!("[{}] TAURI_APP not initialized", miniapp_id); + return; + } + }; + + let data = bytes; + tauri::async_runtime::spawn(async move { + let state = app.state::(); + + let topic = match state.get_realtime_channel(&miniapp_id).await { + Some(t) => t, + None => { + log_warn!("[WEBXDC] Android sendRealtimeData: no active channel for {}", miniapp_id); + return; + } + }; + + let iroh = match state.realtime.get_or_init().await { + Ok(iroh) => iroh, + Err(e) => { + log_error!("[WEBXDC] Android sendRealtimeData: failed to get Iroh: {}", e); + return; + } + }; + + if let Err(e) = iroh.send_data(topic, data).await { + log_error!("[WEBXDC] Android sendRealtimeData: failed to send: {}", e); + } + }); +} + /// Get the user's npub. #[no_mangle] pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_getSelfAddrNative( @@ -1139,8 +1248,15 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { }}, send: function(data) {{ var buf = data instanceof Uint8Array ? data : new Uint8Array(data); - // WebSocket fast path — silently drop if not connected yet - if (rtWs && rtWs.readyState === 1) rtWs.send(buf); + // Fast path: WebSocket binary frame + if (rtWs && rtWs.readyState === 1) {{ + rtWs.send(buf); + }} else {{ + // Fallback: JNI bridge (always available on Android) + try {{ + window.__MINIAPP_IPC__.sendRealtimeData(b91e(buf)); + }} catch(e) {{}} + }} }}, leave: function() {{ this._listener = null; @@ -1163,6 +1279,14 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { rtWs.binaryType = 'arraybuffer'; rtWs.onclose = function() {{ rtWs = null; }}; rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }}; + // Detect stuck CONNECTING state (some WebViews silently block WS) + setTimeout(function() {{ + if (rtWs && rtWs.readyState === 0) {{ + console.warn('[webxdc] WS stuck in CONNECTING — falling back to JNI'); + try {{ rtWs.close(); }} catch(e) {{}} + rtWs = null; + }} + }}, 1500); }} }} }} catch(e) {{ diff --git a/src-tauri/src/commands/realtime.rs b/src-tauri/src/commands/realtime.rs index cccc20e5..234256be 100644 --- a/src-tauri/src/commands/realtime.rs +++ b/src-tauri/src/commands/realtime.rs @@ -106,35 +106,21 @@ pub async fn send_webxdc_peer_advertisement( Ok(pubkey) => { // This is a DM - use NIP-17 gift wrapping - // Build the peer advertisement rumor + // Build the peer advertisement rumor (no expiry — peer-left signal handles cleanup) let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "peer-advertisement") .tag(Tag::public_key(pubkey)) .tag(Tag::custom(TagKind::d(), vec!["vector-webxdc-peer"])) .tag(Tag::custom(TagKind::custom("webxdc-topic"), vec![topic_id])) .tag(Tag::custom(TagKind::custom("webxdc-node-addr"), vec![node_addr])) - .tag(Tag::expiration(Timestamp::from_secs( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - + 300, // 5 minute expiry - ))) .build(my_public_key); // Gift Wrap and send to receiver via our Trusted Relays - let expiry_time = Timestamp::from_secs( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - + 300, - ); match client .gift_wrap_to( active_trusted_relays().await.into_iter(), &pubkey, rumor, - [Tag::expiration(expiry_time)], + [], ) .await { @@ -146,18 +132,11 @@ pub async fn send_webxdc_peer_advertisement( // This is a group chat - use MLS let group_id = receiver.clone(); - // Build the peer advertisement rumor + // Build the peer advertisement rumor (no expiry — peer-left signal handles cleanup) let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "peer-advertisement") .tag(Tag::custom(TagKind::d(), vec!["vector-webxdc-peer"])) .tag(Tag::custom(TagKind::custom("webxdc-topic"), vec![topic_id])) .tag(Tag::custom(TagKind::custom("webxdc-node-addr"), vec![node_addr])) - .tag(Tag::expiration(Timestamp::from_secs( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - + 300, - ))) .build(my_public_key); // Send via MLS @@ -169,6 +148,53 @@ pub async fn send_webxdc_peer_advertisement( } } +/// Send a "peer-left" signal so other clients know we've stopped playing. +/// Same transport as peer advertisements (NIP-17 DM or MLS group message). +pub async fn send_webxdc_peer_left( + receiver: String, + topic_id: String, +) -> bool { + let client = NOSTR_CLIENT.get().expect("Nostr client not initialized"); + let my_public_key = *crate::MY_PUBLIC_KEY.get().expect("Public key not initialized"); + + log_info!("Sending WebXDC peer-left to {} for topic {}", receiver, topic_id); + + match PublicKey::from_bech32(receiver.as_str()) { + Ok(pubkey) => { + let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "peer-left") + .tag(Tag::public_key(pubkey)) + .tag(Tag::custom(TagKind::d(), vec!["vector-webxdc-peer"])) + .tag(Tag::custom(TagKind::custom("webxdc-topic"), vec![topic_id])) + .build(my_public_key); + + match client + .gift_wrap_to( + active_trusted_relays().await.into_iter(), + &pubkey, + rumor, + [], + ) + .await + { + Ok(_) => true, + Err(_) => false, + } + } + Err(_) => { + let group_id = receiver; + let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "peer-left") + .tag(Tag::custom(TagKind::d(), vec!["vector-webxdc-peer"])) + .tag(Tag::custom(TagKind::custom("webxdc-topic"), vec![topic_id])) + .build(my_public_key); + + match mls::send_mls_message(&group_id, rumor, None).await { + Ok(_) => true, + Err(_) => false, + } + } + } +} + // ============================================================================ // Live Subscriptions // ============================================================================ diff --git a/src-tauri/src/db/miniapps.rs b/src-tauri/src/db/miniapps.rs index 58ca5707..a8170114 100644 --- a/src-tauri/src/db/miniapps.rs +++ b/src-tauri/src/db/miniapps.rs @@ -458,4 +458,72 @@ pub fn copy_miniapp_permissions( Ok(()) +} + +// ============================================================================ +// Peer Advertisement Queries (for realtime channel discovery) +// ============================================================================ + +/// A persisted peer advertisement record from SQLite. +pub struct PeerAdvertisementRecord { + pub npub: String, + pub node_addr_encoded: String, +} + +/// Get active peer advertisements for a topic. +/// +/// Returns advertisements where the sender's most recent event for this topic +/// is a "peer-advertisement" (not invalidated by a subsequent "peer-left"). +/// Excludes our own npub. Uses `reference_id` = topic_encoded (indexed). +pub fn get_active_peer_advertisements( + topic_encoded: &str, + my_npub: &str, +) -> Result, String> { + let conn = crate::account_manager::get_db_connection_guard_static()?; + + let mut stmt = conn.prepare( + r#" + SELECT e.npub, e.tags, e.created_at + FROM events e + INNER JOIN ( + SELECT npub, MAX(created_at) as max_ts + FROM events + WHERE kind = 30078 + AND reference_id = ?1 + AND content IN ('peer-advertisement', 'peer-left') + AND npub IS NOT NULL + AND npub != ?2 + GROUP BY npub + ) latest ON e.npub = latest.npub AND e.created_at = latest.max_ts + WHERE e.kind = 30078 + AND e.reference_id = ?1 + AND e.content = 'peer-advertisement' + "# + ).map_err(|e| format!("Failed to prepare peer advertisement query: {}", e))?; + + let records = stmt.query_map( + rusqlite::params![topic_encoded, my_npub], + |row| { + let npub: String = row.get(0)?; + let tags_json: String = row.get(1)?; + let _: i64 = row.get(2)?; // created_at consumed by query join + + // Extract node_addr from tags JSON + let tags: Vec> = serde_json::from_str(&tags_json).unwrap_or_default(); + let node_addr = tags.iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("webxdc-node-addr")) + .and_then(|t| t.get(1)) + .cloned() + .unwrap_or_default(); + + let _: i64 = row.get(2)?; // created_at used by query ordering + + Ok(PeerAdvertisementRecord { + npub, + node_addr_encoded: node_addr, + }) + } + ).map_err(|e| format!("Failed to query peer advertisements: {}", e))?; + + Ok(records.filter_map(|r| r.ok()).filter(|r| !r.node_addr_encoded.is_empty()).collect()) } \ No newline at end of file diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index b1fa0991..14d2975d 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -39,6 +39,7 @@ pub use miniapps::{ get_miniapp_granted_permissions, set_miniapp_permission, set_miniapp_permissions, has_miniapp_permission_prompt, revoke_all_miniapp_permissions, copy_miniapp_permissions, save_marketplace_cache, load_marketplace_cache, + get_active_peer_advertisements, }; // Chat database functions pub use chats::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30ffb322..576e06fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -283,21 +283,6 @@ pub fn run() { profile_sync::start_profile_sync_processor().await; }); - // Start the Mini Apps pending peer cleanup task (runs every 5 minutes) - { - let handle_for_cleanup = handle.clone(); - tauri::async_runtime::spawn(async move { - loop { - // Wait 5 minutes between cleanups - tokio::time::sleep(std::time::Duration::from_secs(300)).await; - - // Get the MiniAppsState and run cleanup - let state = handle_for_cleanup.state::(); - state.cleanup_expired_pending_peers().await; - } - }); - } - // Setup deep link listener for macOS/iOS/Android // On these platforms, deep links are received as events rather than CLI args #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))] diff --git a/src-tauri/src/miniapps/commands.rs b/src-tauri/src/miniapps/commands.rs index abe21f30..949a613b 100644 --- a/src-tauri/src/miniapps/commands.rs +++ b/src-tauri/src/miniapps/commands.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tauri::ipc::Channel; -use futures_util::future::join_all; #[cfg(not(target_os = "android"))] use std::sync::Arc; @@ -11,6 +10,7 @@ use std::sync::Arc; use tauri::{WebviewUrl, WebviewWindowBuilder}; use serde::{Deserialize, Serialize}; +use nostr_sdk::prelude::ToBech32; use super::error::Error; use super::state::{MiniAppInstance, MiniAppsState, MiniAppPackage, RealtimeChannelState}; use super::realtime::{RealtimeEvent, EventTarget, encode_topic_id, encode_node_addr}; @@ -791,6 +791,130 @@ pub async fn miniapp_open( // Register the instance before creating the window state.add_instance(instance.clone()).await; + // Preconnect: if this Mini App uses the realtime API, create the gossip channel + // and connect to peers in the background. joinRealtimeChannel() awaits the + // signal, then just attaches the event listener — preconnect is the sole initiator. + let (pc_tx, pc_rx) = tokio::sync::watch::channel(false); + state.set_preconnect_signal(&window_label, pc_rx).await; + { + let app_pc = app.clone(); + let pkg_path = package.path.clone(); + let pkg_name = package.manifest.name.clone(); + let chat_id_pc = chat_id.clone(); + let msg_id_pc = message_id.clone(); + let topic_str = topic_id.clone(); + let rt_topic = instance.realtime_topic; + let label_pc = window_label.clone(); + tokio::spawn(async move { + log_info!("[WEBXDC] Preconnect: scanning '{}' for realtime API (path: {:?})", pkg_name, pkg_path); + let uses_rt = tokio::task::spawn_blocking(move || { + MiniAppPackage::scan_for_realtime_api(&pkg_path) + }).await.unwrap_or(false); + + if !uses_rt { + log_info!("[WEBXDC] Preconnect: '{}' does NOT use realtime API, skipping", pkg_name); + drop(pc_tx); + return; + } + log_info!("[WEBXDC] Preconnect: '{}' uses realtime API, initializing Iroh", pkg_name); + + // Compute topic (same logic as joinRealtimeChannel) + let topic = rt_topic.unwrap_or_else(|| { + super::realtime::derive_topic_id(&pkg_name, &chat_id_pc, &msg_id_pc) + }); + let topic_encoded = match topic_str { + Some(ts) => ts, + None => super::realtime::encode_topic_id(&topic), + }; + + let state = app_pc.state::(); + let iroh = match state.realtime.get_or_init().await { + Ok(i) => i, + Err(e) => { log_warn!("[WEBXDC] Preconnect: Iroh init failed: {e}"); return; } + }; + + // Create gossip channel with NO event target. Incoming data is BUFFERED + // (not dropped) until joinRealtimeChannel sets the target and flushes. + // This lets us form the gossip mesh NOW so peers are connected before + // the game starts its election/handshake. + if let Err(e) = iroh.join_channel(topic, vec![], None, Some(app_pc.clone()), label_pc.clone()).await { + log_warn!("[WEBXDC] Preconnect: join_channel failed: {e}"); + return; + } + + state.set_realtime_channel(&label_pc, super::state::RealtimeChannelState { + topic, active: true, + }).await; + + // Send advertisement so peers know we're online + let node_addr = iroh.get_node_addr(); + if let Ok(encoded) = super::realtime::encode_node_addr(&node_addr) { + crate::commands::realtime::send_webxdc_peer_advertisement( + chat_id_pc, topic_encoded.clone(), encoded, + ).await; + } + + if let Some(pk) = crate::MY_PUBLIC_KEY.get() { + let npub = pk.to_bech32().unwrap(); + state.add_session_peer(topic, npub).await; + } + + if let Some(main_window) = app_pc.get_webview_window("main") { + let session_peers = state.get_session_peers(&topic).await; + let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ + "topic": topic_encoded, + "peer_count": session_peers.len(), + "peers": session_peers, + "is_active": true, + })); + } + + log_info!("[WEBXDC] Preconnect: Iroh ready for '{}'", label_pc); + let _ = pc_tx.send(true); + + // Connect to known peers (runs after signal — doesn't block joinRealtimeChannel). + // Single attempt, 5s timeout — stale peers fail fast. + let my_npub = crate::MY_PUBLIC_KEY.get() + .and_then(|pk| nostr_sdk::prelude::ToBech32::to_bech32(pk).ok()) + .unwrap_or_default(); + let mut connected_ids = std::collections::HashSet::new(); + + if let Ok(records) = crate::db::get_active_peer_advertisements(&topic_encoded, &my_npub) { + if !records.is_empty() { + log_info!("[WEBXDC] Preconnect: trying {} persisted peers", records.len()); + for record in &records { + state.add_session_peer(topic, record.npub.clone()).await; + } + let peers: Vec<_> = records.iter().filter_map(|r| { + match super::realtime::decode_node_addr(&r.node_addr_encoded) { + Ok(addr) => { connected_ids.insert(addr.id); Some(addr) } + Err(e) => { log_warn!("[WEBXDC] Preconnect: bad peer addr: {e}"); None } + } + }).collect(); + for addr in peers { + let peer_id = addr.id; + match tokio::time::timeout( + std::time::Duration::from_secs(5), + iroh.try_add_peer(&topic, &addr), + ).await { + Ok(Ok(_)) => log_info!("[WEBXDC] Preconnect: connected to peer {}", peer_id), + Ok(Err(e)) => log_trace!("[WEBXDC] Preconnect: peer {} failed: {e}", peer_id), + Err(_) => log_trace!("[WEBXDC] Preconnect: peer {} timed out (stale?)", peer_id), + } + } + } + } + + let cached = state.take_peer_addrs(&topic).await; + for addr in cached.into_iter().filter(|a| !connected_ids.contains(&a.id)) { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + iroh.try_add_peer(&topic, &addr), + ).await; + } + }); + } + // ======================================== // Android: Use native WebView overlay // ======================================== @@ -909,36 +1033,51 @@ pub async fn miniapp_open( let label = window_label_for_handler.clone(); tauri::async_runtime::spawn(async move { let state = app_handle.state::(); - - // Get the channel state before removing to get the topic - // We remove the channel state (marking us as not playing) but DON'T leave the Iroh channel - // This way we can still see other players' peer count + + // Full teardown: remove channel state, leave QUIC, clean session peers let channel_state = state.remove_realtime_channel(&label).await; - + if let Some(channel) = channel_state { let topic_encoded = super::realtime::encode_topic_id(&channel.topic); - // Get current peer count and clear the stale event target. - // Clearing prevents the subscribe loop from logging errors - // on every received message after the window is gone. - let peer_count = if let Ok(iroh) = state.realtime.get_or_init().await { - iroh.clear_event_target(&channel.topic).await; - iroh.get_peer_count(&channel.topic).await - } else { - 0 - }; - - // Emit status update to main window - we're no longer playing but can still see peers + // Tear down the QUIC channel completely (close connections, abort tasks) + if let Ok(iroh) = state.realtime.get_or_init().await { + if let Err(e) = iroh.leave_channel(channel.topic, &label).await { + log_warn!("[WEBXDC] Failed to leave channel on close: {}", e); + } + } + + // Remove ourselves from session peers + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + let my_npub = my_pk.to_bech32().unwrap(); + state.remove_session_peer(&channel.topic, &my_npub).await; + } + + // Emit status update — session_peers is the single source of truth + let session_peers = state.get_session_peers(&channel.topic).await; + let peer_count = session_peers.len(); if let Some(main_window) = app_handle.get_webview_window("main") { let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ "topic": topic_encoded, "peer_count": peer_count, + "peers": session_peers, "is_active": false, "has_pending_peers": peer_count > 0, })); } + + // Send peer-left via Nostr so other clients update their lobby state + if let Some(instance) = state.get_instance(&label).await { + let chat_id = instance.chat_id.clone(); + let topic_for_left = topic_encoded.clone(); + tokio::spawn(async move { + if !crate::commands::realtime::send_webxdc_peer_left(chat_id, topic_for_left).await { + log_warn!("[WEBXDC] Failed to send peer-left signal"); + } + }); + } } - + // Remove the instance state.remove_instance(&label).await; }); @@ -1122,8 +1261,10 @@ pub struct JoinRealtimeResult { pub ws_url: Option, } -/// Join the realtime channel for a Mini App -/// Returns the topic ID and WebSocket URL for the fast-path send +/// Join the realtime channel for a Mini App. +/// Preconnect (spawned in miniapp_open) pre-initializes Iroh and sends the advertisement. +/// This function creates the gossip channel WITH the event target (so no data is dropped), +/// then connects to known peers. Thanks to preconnect, Iroh is already init'd (~300ms vs 2-5s). #[tauri::command] pub async fn miniapp_join_realtime_channel( window: WebviewWindow, @@ -1137,17 +1278,9 @@ pub async fn miniapp_join_realtime_channel( return Err(Error::InstanceNotFoundByLabel(label.to_string())); } - // Check if already has an active channel - if state.has_realtime_channel(label).await { - return Err(Error::RealtimeChannelAlreadyActive); - } - - // Get the instance to get the topic from the webxdc-topic tag let instance = state.get_instance(label).await .ok_or_else(|| Error::InstanceNotFoundByLabel(label.to_string()))?; - // Use the topic from the Nostr event's webxdc-topic tag if available - // Otherwise, derive a topic from the chat_id and message_id for local testing let topic = if let Some(t) = instance.realtime_topic { t } else { @@ -1155,105 +1288,113 @@ pub async fn miniapp_join_realtime_channel( super::realtime::derive_topic_id(&instance.package.manifest.name, &instance.chat_id, &instance.message_id) }; - // Get the realtime manager and join the channel + let topic_encoded = encode_topic_id(&topic); + + // Wait for preconnect to finish initializing Iroh (up to 10s). + // Preconnect handles: Iroh init (2-5s), advertisement, session peers, status. + // If preconnect already finished, this returns instantly. + if let Some(mut rx) = state.take_preconnect_signal(label).await { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + rx.wait_for(|ready| *ready), + ).await; + } + + // Iroh is pre-initialized by preconnect — this is instant (~5ns atomic load) let iroh = state.realtime.get_or_init().await .map_err(|e| Error::RealtimeError(e.to_string()))?; - - // Join the Iroh gossip channel with no initial peers - // Peers will be added via advertisements - let event_target = EventTarget::TauriChannel(channel.clone()); - let (is_rejoin, _join_rx) = iroh.join_channel(topic, vec![], event_target, Some(app.clone()), label.to_string()).await + + // Create the gossip channel WITH the event target — no data can be dropped + let event_target = EventTarget::TauriChannel(channel); + let (is_rejoin, _) = iroh.join_channel(topic, vec![], Some(event_target), Some(app.clone()), label.to_string()).await .map_err(|e| Error::RealtimeError(e.to_string()))?; - - let topic_encoded = encode_topic_id(&topic); - + + let topic_encoded_clone = topic_encoded.clone(); if is_rejoin { - log_info!("Re-joined existing realtime channel for Mini App: {} (topic: {})", label, topic_encoded); + log_info!("[WEBXDC] Re-joined existing channel: {} (topic: {})", label, topic_encoded); } else { - log_info!("Joined new realtime channel for Mini App: {} (topic: {})", label, topic_encoded); + log_info!("[WEBXDC] Joined new channel: {} (topic: {})", label, topic_encoded); } - - // Store/update the channel state - // This is important for re-joins: the old event target is stale (old window closed) - let channel_state = RealtimeChannelState { - topic, - active: true, - }; - state.set_realtime_channel(label, channel_state).await; - - // Check for pending peers that advertised before we joined - let pending_peers = state.take_pending_peers(&topic).await; - let pending_peer_count = pending_peers.len(); - log_info!("[WEBXDC] Checking for pending peers for topic {}: found {}", topic_encoded, pending_peer_count); - if !pending_peers.is_empty() { - log_info!("[WEBXDC] Found {} pending peers for topic {}, adding concurrently", pending_peer_count, topic_encoded); - - // Add all pending peers concurrently for faster connection establishment - let add_peer_futures: Vec<_> = pending_peers.into_iter().map(|pending| { - let iroh_ref = &iroh; - async move { - let peer_id = pending.node_addr.id; - match iroh_ref.add_peer(topic, pending.node_addr).await { - Ok(_) => { - log_info!("[WEBXDC] Successfully added pending peer {} to realtime channel", peer_id); - Ok(peer_id) + + state.set_realtime_channel(label, RealtimeChannelState { topic, active: true }).await; + + if !is_rejoin { + // Preconnect didn't run (scan false negative or non-realtime app). + // Do peer connections + advertisement here as fallback. + let my_npub = crate::MY_PUBLIC_KEY.get() + .and_then(|pk| ToBech32::to_bech32(pk).ok()) + .unwrap_or_default(); + let mut connected_ids = std::collections::HashSet::new(); + + if let Ok(records) = crate::db::get_active_peer_advertisements(&topic_encoded, &my_npub) { + if !records.is_empty() { + log_info!("[WEBXDC] Connecting to {} persisted peers for topic {}", records.len(), topic_encoded); + for record in &records { + state.add_session_peer(topic, record.npub.clone()).await; + } + let peers: Vec<_> = records.iter().filter_map(|record| { + match super::realtime::decode_node_addr(&record.node_addr_encoded) { + Ok(addr) => { connected_ids.insert(addr.id); Some(addr) } + Err(e) => { log_warn!("[WEBXDC] Failed to decode persisted peer addr: {}", e); None } } - Err(e) => { - log_warn!("[WEBXDC] Failed to add pending peer {}: {}", peer_id, e); - Err((peer_id, e)) + }).collect(); + for addr in peers { + let peer_id = addr.id; + match tokio::time::timeout( + std::time::Duration::from_secs(5), + iroh.try_add_peer(&topic, &addr), + ).await { + Ok(Ok(_)) => log_info!("[WEBXDC] Connected to persisted peer {}", peer_id), + Ok(Err(e)) => log_trace!("[WEBXDC] Peer {} failed: {e}", peer_id), + Err(_) => log_trace!("[WEBXDC] Peer {} timed out (stale?)", peer_id), } } } - }).collect(); - - let results = join_all(add_peer_futures).await; - let success_count = results.iter().filter(|r| r.is_ok()).count(); - let fail_count = results.len() - success_count; - - if fail_count > 0 { - log_warn!("[WEBXDC] Added {}/{} pending peers ({} failed)", success_count, results.len(), fail_count); - } else { - log_info!("[WEBXDC] Successfully added all {} pending peers", success_count); } - } else { - log_trace!("[WEBXDC] No pending peers found for topic {}", topic_encoded); + + let cached_addrs = state.take_peer_addrs(&topic).await; + for addr in cached_addrs.into_iter().filter(|a| !connected_ids.contains(&a.id)) { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + iroh.try_add_peer(&topic, &addr), + ).await; + } } - - // Get our node address and send a peer advertisement to the chat - // This allows other participants to discover and connect to us - let node_addr = iroh.get_node_addr(); - let node_addr_encoded = encode_node_addr(&node_addr) - .map_err(|e| Error::RealtimeError(e.to_string()))?; - - // Send peer advertisement to the chat so other participants can discover us. - // Late joiners will pick this up from the relay backlog via pending peers. - let chat_id = instance.chat_id.clone(); - let topic_for_advert = topic_encoded.clone(); - let node_addr_for_advert = node_addr_encoded.clone(); - tokio::spawn(async move { - if !crate::commands::realtime::send_webxdc_peer_advertisement(chat_id, topic_for_advert, node_addr_for_advert).await { - log_warn!("[WEBXDC] Failed to send peer advertisement"); + + // Send advertisement if preconnect didn't + if !is_rejoin { + let node_addr = iroh.get_node_addr(); + if let Ok(encoded) = encode_node_addr(&node_addr) { + let chat_id = instance.chat_id.clone(); + let te = topic_encoded.clone(); + tokio::spawn(async move { + crate::commands::realtime::send_webxdc_peer_advertisement(chat_id, te, encoded).await; + }); } - }); + } + + // Add self + emit status + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + let npub = my_pk.to_bech32().unwrap(); + state.add_session_peer(topic, npub).await; + } - // Emit status event to main window so UI updates to show "Playing" if let Some(main_window) = app.get_webview_window("main") { - let current_peer_count = iroh.get_peer_count(&topic).await; - let effective_peer_count = std::cmp::max(current_peer_count, pending_peer_count); + let session_peers = state.get_session_peers(&topic).await; let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ - "topic": topic_encoded.clone(), - "peer_count": effective_peer_count, + "topic": topic_encoded_clone, + "peer_count": session_peers.len(), + "peers": session_peers, "is_active": true, })); } - // Return the topic + WebSocket URL for the fast-path send let ws_url = state.realtime.ws_url(); Ok(JoinRealtimeResult { topic: topic_encoded, ws_url }) } -/// Send realtime data (invoke fallback for platforms where WebSocket is unavailable, e.g. Linux/WebKitGTK). -/// On macOS/Windows the WS fast-path is used instead; this command is only called when WS fails. +/// Send realtime data via invoke fallback (used when WS fast-path isn't available). +/// Accepts raw bytes (Array.from(Uint8Array)) to avoid base91 encode/decode overhead. #[tauri::command] pub async fn miniapp_send_realtime_data( window: WebviewWindow, @@ -1261,9 +1402,21 @@ pub async fn miniapp_send_realtime_data( data: Vec, ) -> Result<(), Error> { let label = window.label(); - if let Ok(iroh) = state.realtime.get_or_init().await { - iroh.fast_send(label, data); + + if data.len() > 128_000 { + return Err(Error::RealtimeError(format!("Data too large: {} bytes", data.len()))); } + + // Get the topic for this instance + let topic = state.get_realtime_channel(label).await + .ok_or(Error::RealtimeChannelNotActive)?; + + let iroh = state.realtime.get_or_init().await + .map_err(|e| Error::RealtimeError(e.to_string()))?; + + iroh.send_data(topic, data).await + .map_err(|e| Error::RealtimeError(e.to_string()))?; + Ok(()) } @@ -1352,6 +1505,8 @@ pub struct RealtimeChannelInfo { pub pending_peer_count: usize, /// Topic ID (encoded) pub topic_id: String, + /// Npubs of peers in the session (for avatar display) + pub peers: Vec, } /// Get the realtime channel status for a topic @@ -1364,34 +1519,23 @@ pub async fn miniapp_get_realtime_status( let topic = super::realtime::decode_topic_id(&topic_id) .map_err(|e| Error::RealtimeError(e.to_string()))?; - let pending_peer_count = state.get_pending_peer_count(&topic).await; - // Check if WE are actively playing (have a Mini App window open for this topic) - // This is different from whether we have an Iroh channel (which we keep open to see peers) let we_are_playing = { let channels = state.realtime_channels.read().await; channels.values().any(|ch| ch.topic == topic && ch.active) }; - match state.realtime.get_or_init().await { - Ok(iroh) => { - let peer_count = iroh.get_peer_count(&topic).await; - Ok(RealtimeChannelInfo { - active: we_are_playing, - peer_count, - pending_peer_count, - topic_id, - }) - } - Err(_) => { - Ok(RealtimeChannelInfo { - active: false, - peer_count: 0, - pending_peer_count, - topic_id, - }) - } - } + // session_peers is the single source of truth for both count and avatars + let peer_npubs = state.get_session_peers(&topic).await; + let peer_count = peer_npubs.len(); + + Ok(RealtimeChannelInfo { + active: we_are_playing, + peer_count, + pending_peer_count: 0, + topic_id, + peers: peer_npubs, + }) } // ============================================================================ diff --git a/src-tauri/src/miniapps/realtime.rs b/src-tauri/src/miniapps/realtime.rs index 50819843..0a2ba519 100644 --- a/src-tauri/src/miniapps/realtime.rs +++ b/src-tauri/src/miniapps/realtime.rs @@ -1,7 +1,8 @@ -//! Realtime peer channels for Mini Apps using raw QUIC connections via Iroh. +//! Realtime peer channels for Mini Apps using Iroh Gossip //! -//! Each peer gets independent QUIC connections with per-peer send queues — -//! one slow peer cannot block others (no head-of-line blocking). +//! This module provides P2P realtime communication for WebXDC apps using Iroh's +//! gossip protocol for reliable message delivery, with an optional WebSocket +//! fast-path for zero-overhead binary sends from the Mini App JS. //! //! See: https://webxdc.org/docs/spec/joinRealtimeChannel.html @@ -9,10 +10,14 @@ use anyhow::{anyhow, bail, Context as _, Result}; use fast_thumbhash::base91_encode; +use futures_util::StreamExt; use iroh::endpoint::VarInt; use iroh::{EndpointAddr, Endpoint, PublicKey, RelayMode, SecretKey, TransportAddr}; +use iroh_gossip::api::{Event, GossipReceiver, GossipSender, JoinOptions}; +use iroh_gossip::net::{Gossip, GOSSIP_ALPN}; +pub use iroh_gossip::proto::TopicId; use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; use std::sync::Arc; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager}; @@ -61,77 +66,44 @@ fn base32_nopad_decode(encoded: &[u8]) -> std::result::Result, String> { Ok(out) } -// ─── Constants ────────────────────────────────────────────────────────────── - -/// Custom ALPN protocol for Vector realtime P2P channels. -const VECTOR_RT_ALPN: &[u8] = b"vector-rt/1"; - /// Maximum message size for realtime data (128 KB as per WebXDC spec) const MAX_MESSAGE_SIZE: usize = 128 * 1024; -/// Bounded global send queue capacity — large enough for bursts, bounded to cap memory. -/// At 128 KB max payload, worst case is 256 * 128 KB = 32 MB buffered. -const SEND_QUEUE_CAPACITY: usize = 256; - -/// Per-peer send queue capacity. Smaller than the global queue because -/// a single slow peer should not buffer unboundedly. -/// At 128 KB max, worst case is 64 * 128 KB = 8 MB per peer. -const PEER_SEND_QUEUE_CAPACITY: usize = 64; - /// The length of an ed25519 PublicKey, in bytes. -pub(crate) const PUBLIC_KEY_LENGTH: usize = 32; - -// ─── TopicId ──────────────────────────────────────────────────────────────── +const PUBLIC_KEY_LENGTH: usize = 32; -/// Topic identifier for realtime channels (32-byte hash). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TopicId([u8; 32]); - -impl TopicId { - pub fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } - pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } -} +/// Trailer length: 4 bytes seq + 32 bytes pubkey +const TRAILER_LEN: usize = 4 + PUBLIC_KEY_LENGTH; // ─── SendHandle ───────────────────────────────────────────────────────────── -/// Sync-accessible handle for the zero-overhead send fast path. -/// Cached per window label so the WS server can skip all async lookups. +/// Handle for the WS fast-path: wraps the gossip sender + seq counter +/// so the WS server can send without going through Tauri invoke. pub(crate) struct SendHandle { - pub send_tx: tokio::sync::mpsc::Sender>, - pub drops: Arc, + pub sender: GossipSender, + pub seq: Arc, + pub public_key_bytes: [u8; PUBLIC_KEY_LENGTH], } impl std::fmt::Debug for SendHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SendHandle") - .field("drops", &self.drops.load(Ordering::Relaxed)) - .finish() + f.debug_struct("SendHandle").finish() } } -// ─── PeerConnection ───────────────────────────────────────────────────────── - -/// A single peer's QUIC connection and associated tasks. -struct PeerConnection { - /// The QUIC connection to this peer. - conn: iroh::endpoint::Connection, - /// Background task reading incoming uni streams from this peer. - read_task: JoinHandle<()>, - /// Background task writing to uni streams for this peer. - write_task: JoinHandle<()>, - /// Per-peer send queue (drainer fans out here; non-blocking try_send). - send_tx: tokio::sync::mpsc::Sender>, -} - // ─── IrohState ────────────────────────────────────────────────────────────── /// Store Iroh peer channels state +#[derive(Debug)] pub struct IrohState { - /// Iroh QUIC endpoint for P2P connections + /// Iroh endpoint for peer channels pub(crate) endpoint: Endpoint, - /// Active realtime channels (Arc for sharing with accept loop, drainer, and peer tasks) - pub(crate) channels: Arc>>, + /// Gossip protocol handler + pub(crate) gossip: Gossip, + + /// Active realtime channels + pub(crate) channels: RwLock>, /// Our public key (attached to messages for deduplication) pub(crate) public_key: PublicKey, @@ -139,24 +111,16 @@ pub struct IrohState { /// Cached public key bytes (avoids repeated .as_bytes() calls in hot path) pub(crate) public_key_bytes: [u8; PUBLIC_KEY_LENGTH], - /// Fast-path send handles keyed by window label (sync access via std RwLock). - /// Wrapped in Arc so the WS server can share the same map. - /// Populated on join_channel, removed on leave_channel. + /// Fast-path send handles (owned by RealtimeManager, shared via Arc). pub(crate) send_handles: Arc>>, } -impl std::fmt::Debug for IrohState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("IrohState") - .field("public_key", &self.public_key) - .finish() - } -} - impl IrohState { - /// Initialize a new Iroh state with QUIC endpoint (no gossip) - pub async fn new(_relay_url: Option) -> Result { - log_info!("Initializing Iroh peer channels (raw QUIC)"); + /// Initialize a new Iroh state with endpoint and gossip. + /// `send_handles` is owned by RealtimeManager and passed in so the WS server + /// can start before IrohState exists (critical for Android JNI timing). + pub async fn new(_relay_url: Option, send_handles: Arc>>) -> Result { + log_info!("Initializing Iroh peer channels (gossip)"); // Generate 32 random bytes and construct SecretKey from them // (avoids rand_core version mismatch between our rand 0.8 and iroh's rand_core 0.9) @@ -166,7 +130,7 @@ impl IrohState { let public_key = secret_key.public(); // Build a QUIC transport config tuned for realtime gaming/streaming. - // Handles extreme RTT scenarios (500ms–5000ms) for globe-spanning connections. + // CRITICAL: These values are hard-won from 20+ hours of debugging. let transport_config = iroh::endpoint::QuicTransportConfig::builder() .keep_alive_interval(std::time::Duration::from_secs(15)) .max_idle_timeout(Some(std::time::Duration::from_secs(120).try_into()?)) @@ -176,49 +140,67 @@ impl IrohState { .max_concurrent_bidi_streams(VarInt::from_u32(256)) .max_concurrent_uni_streams(VarInt::from_u32(256)) .initial_rtt(std::time::Duration::from_millis(100)) - // QUIC multipath: simultaneous WiFi + cellular + relay paths - .max_concurrent_multipath_paths(3) - .default_path_keep_alive_interval(std::time::Duration::from_secs(10)) - .default_path_max_idle_timeout(std::time::Duration::from_secs(120)) - // BBR congestion control for high BDP intercontinental links - .congestion_controller_factory(Arc::new( - iroh_quinn_proto::congestion::BbrConfig::default() - )) + // BBR congestion control — better throughput and latency than NewReno + // under relay conditions (bufferbloat, variable RTT) + .congestion_controller_factory(Arc::new(noq_proto::congestion::BbrConfig::default())) + // Rule 2: Disable observed address reports (prevents QUIC learning direct IPs + // during handshake → routes data to unreachable path → one-way data loss) + .send_observed_address_reports(false) + .receive_observed_address_reports(false) .build(); - // Build the endpoint with our custom ALPN - let endpoint = Endpoint::builder() + // Rule 1: Relay-only preset — NO N0 pkarr/DNS address discovery. + // N0 preset publishes IPs via pkarr DNS → path migration → connection death. + use iroh::endpoint::presets::Preset; + struct RelayOnly; + impl Preset for RelayOnly { + fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder { + builder.relay_mode(RelayMode::Default) + } + } + + let endpoint = Endpoint::builder(RelayOnly) .secret_key(secret_key) - .alpns(vec![VECTOR_RT_ALPN.to_vec()]) - .relay_mode(RelayMode::Default) + .alpns(vec![GOSSIP_ALPN.to_vec()]) .transport_config(transport_config) .bind() .await?; - // Relay connects automatically in the background (no manual wait needed) - log_info!("[WEBXDC] Endpoint bound, relay connection will establish in background"); + // Rule 3: Wait for relay so get_node_addr() includes relay URL in advertisements. + // Without this, advertisements have no relay URL and peers can't connect. + tokio::time::timeout( + std::time::Duration::from_secs(5), + endpoint.online(), + ).await.ok(); + for _ in 0..20 { + if endpoint.addr().addrs.iter().any(|a| matches!(a, TransportAddr::Relay(_))) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + log_info!("[WEBXDC] Endpoint bound, relay ready"); - let public_key_bytes = *public_key.as_bytes(); - let channels: Arc>> = Arc::new(RwLock::new(HashMap::new())); + // Create gossip with max message size of 128 KB + let gossip = Gossip::builder() + .max_message_size(MAX_MESSAGE_SIZE) + .spawn(endpoint.clone()); - // Start the accept loop to handle incoming peer connections + // Accept loop: handle incoming gossip connections let accept_endpoint = endpoint.clone(); - let accept_channels = channels.clone(); - let accept_our_key = public_key_bytes; + let accept_gossip = gossip.clone(); tokio::spawn(async move { log_info!("[WEBXDC] Starting connection accept loop"); loop { match accept_endpoint.accept().await { Some(incoming) => { - let channels = accept_channels.clone(); + let gossip = accept_gossip.clone(); tokio::spawn(async move { match incoming.await { Ok(conn) => { - if conn.alpn() != VECTOR_RT_ALPN { - return; - } - if let Err(e) = handle_incoming_peer(conn, channels, accept_our_key).await { - log_warn!("[WEBXDC] Failed to handle incoming peer: {e}"); + if conn.alpn() == GOSSIP_ALPN { + if let Err(e) = gossip.handle_connection(conn).await { + log_error!("[WEBXDC] Failed to handle gossip connection: {}", e); + } } } Err(e) => { @@ -235,12 +217,17 @@ impl IrohState { } }); + log_info!("[WEBXDC] Accept loop started, gossip ready"); + + let public_key_bytes = *public_key.as_bytes(); + Ok(Self { endpoint, - channels, + gossip, + channels: RwLock::new(HashMap::new()), public_key, public_key_bytes, - send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())), + send_handles, }) } @@ -250,109 +237,57 @@ impl IrohState { } /// Get our endpoint address for peer discovery. - /// Includes LAN/private addresses for direct same-network P2P (~1ms latency) - /// but strips public IPs to preserve privacy. Remote peers fall back to relay. + /// Rule 4: Only relay URLs — direct IPs cause path migration issues. pub fn get_node_addr(&self) -> EndpointAddr { let addr = self.endpoint.addr(); - // Filter: keep relay URLs + only LAN IPs (strip public IPs for privacy) - let filtered_addrs = addr.addrs.into_iter() - .filter(|ta| match ta { - TransportAddr::Relay(_) => true, - TransportAddr::Ip(sa) => is_lan_addr(&sa.ip()), - _ => false, // Unknown transport types — strip for safety - }) + let relay_only: std::collections::BTreeSet<_> = addr.addrs.into_iter() + .filter(|ta| matches!(ta, TransportAddr::Relay(_))) .collect(); - EndpointAddr { id: addr.id, addrs: filtered_addrs } + EndpointAddr { id: addr.id, addrs: relay_only } } - /// Join a realtime channel and start per-peer tasks. - /// `label` is the window label (e.g. "miniapp:abc123") used for fast-path send caching. + /// Join a gossip topic and start the subscriber loop pub async fn join_channel( &self, topic: TopicId, peers: Vec, - event_target: EventTarget, + event_target: Option, app_handle: Option, label: String, ) -> Result<(bool, Option>)> { let mut channels = self.channels.write().await; // If channel already exists, we're re-joining (e.g., user closed and reopened the game) - // Update the shared event target so the reader tasks use the new frontend channel + // Update the shared event target so the subscribe loop uses the new frontend channel if let Some(channel_state) = channels.get(&topic) { - log_info!("IROH_REALTIME: Re-joining existing topic {:?}, updating event target", topic); - let mut shared_target = channel_state.event_target.write().unwrap_or_else(|e| e.into_inner()); - *shared_target = Some(event_target); + log_info!("IROH_REALTIME: Re-joining existing gossip topic {:?}, updating event target", topic); + if let Some(target) = event_target { + let mut state = channel_state.event_target.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }); + state.set_target(target); // Flushes buffered events from preconnect phase + } return Ok((true, None)); } + let peer_ids: Vec = peers.iter().map(|p| p.id).collect(); + log_info!( - "IROH_REALTIME: Joining topic {:?} with {} peers", + "IROH_REALTIME: Joining gossip topic {:?} with {} peers", topic, - peers.len() + peer_ids.len() ); - // Create shared state - let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(Some(event_target))); - let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0)); - - // Bounded send queue: JS invoke returns instantly, drainer fans out in background - let (send_tx, send_rx) = tokio::sync::mpsc::channel::>(SEND_QUEUE_CAPACITY); - let drops = Arc::new(AtomicU64::new(0)); - - // Spawn the fan-out drainer task - let drainer_channels = self.channels.clone(); - let drainer_topic = topic; - let drainer_drops = drops.clone(); - let drainer_handle = tokio::spawn(async move { - run_send_drainer(send_rx, drainer_channels, drainer_topic, drainer_drops).await; - }); - - channels.insert(topic, ChannelState { - peers: HashMap::new(), - drainer_handle, - send_tx: send_tx.clone(), - event_target: shared_event_target.clone(), - peer_count: shared_peer_count.clone(), - drops: drops.clone(), - }); - - // Drop channels lock before connecting to peers (connections may take time) - drop(channels); - - // Populate fast-path send handle for the WS server (zero-overhead sends) - self.send_handles.write().unwrap_or_else(|e| e.into_inner()).insert(label, SendHandle { - send_tx, - drops, - }); - - // Connect to initial peers concurrently - let (join_tx, join_rx) = oneshot::channel(); - let join_tx = Arc::new(std::sync::Mutex::new(Some(join_tx))); - - for peer_addr in peers { + // Connect to peers so gossip can discover them + for peer_addr in &peers { if !peer_addr.addrs.is_empty() { + let addr = peer_addr.clone(); let ep = self.endpoint.clone(); - let channels_ref = self.channels.clone(); - let et = shared_event_target.clone(); - let pc = shared_peer_count.clone(); - let our_key = self.public_key_bytes; - let join_tx_clone = join_tx.clone(); - let app_handle_clone = app_handle.clone(); - let topic_encoded = encode_topic_id(&topic); - + let g = self.gossip.clone(); tokio::spawn(async move { - match connect_to_peer(&ep, peer_addr, topic, et.clone(), pc.clone(), our_key, channels_ref).await { - Ok(_) => { - // Signal connected on first peer - if let Ok(mut guard) = join_tx_clone.lock() { - if let Some(tx) = guard.take() { - let _ = tx.send(()); - send_event(&et, RealtimeEvent::Connected); - } + match ep.connect(addr, GOSSIP_ALPN).await { + Ok(conn) => { + if let Err(e) = g.handle_connection(conn).await { + log_warn!("[WEBXDC] Failed to handle peer connection: {e}"); } - let new_count = pc.load(Ordering::Relaxed); - emit_realtime_status(&app_handle_clone, &topic_encoded, new_count, true); } Err(e) => log_warn!("[WEBXDC] Failed to connect to peer: {e}"), } @@ -360,6 +295,45 @@ impl IrohState { } } + let (join_tx, join_rx) = oneshot::channel(); + + let gossip_topic = self + .gossip + .subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids)) + .await?; + let (gossip_sender, gossip_receiver) = gossip_topic.split(); + + // Create shared event target for the subscribe loop (buffers events if target is None) + let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(EventTargetState::new(event_target))); + let shared_target_clone = shared_event_target.clone(); + + // Create shared peer count + let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0)); + let peer_count_clone = shared_peer_count.clone(); + + let our_key_bytes = self.public_key_bytes; + let topic_encoded = encode_topic_id(&topic); + let sender_for_loop = gossip_sender.clone(); + let subscribe_loop = tokio::spawn(async move { + if let Err(e) = run_subscribe_loop(gossip_receiver, sender_for_loop, topic, shared_target_clone, join_tx, our_key_bytes, peer_count_clone, app_handle, topic_encoded).await { + log_warn!("Subscribe loop failed: {e}"); + } + }); + + let seq = Arc::new(AtomicI32::new(0)); + + channels.insert(topic, ChannelState::new(subscribe_loop, gossip_sender.clone(), shared_event_target, shared_peer_count, seq.clone())); + + // Drop channels lock before populating send_handles + drop(channels); + + // Populate fast-path send handle for the WS server + self.send_handles.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }).insert(label, SendHandle { + sender: gossip_sender, + seq, + public_key_bytes: self.public_key_bytes, + }); + Ok((false, Some(join_rx))) } @@ -368,14 +342,13 @@ impl IrohState { self.add_peer_with_retry(topic, peer, 3).await } - /// Add a peer to a topic with retry logic + /// Add a peer to a gossip topic with retry logic /// Retries with exponential backoff: 1s, 2s, 4s async fn add_peer_with_retry(&self, topic: TopicId, peer: EndpointAddr, max_retries: u32) -> Result<()> { let mut last_error = None; for attempt in 0..max_retries { if attempt > 0 { - // Exponential backoff: 1s, 2s, 4s... let delay = std::time::Duration::from_secs(1 << (attempt - 1)); log_info!("[WEBXDC] add_peer: Retry {} for peer {} after {:?}", attempt, peer.id, delay); tokio::time::sleep(delay).await; @@ -393,57 +366,109 @@ impl IrohState { Err(last_error.unwrap_or_else(|| anyhow!("Failed to add peer after {} retries", max_retries))) } - /// Single attempt to add a peer (internal helper) - async fn try_add_peer(&self, topic: &TopicId, peer: &EndpointAddr) -> Result<()> { - log_trace!("[WEBXDC] add_peer: Connecting to peer {}, addrs={}", - peer.id, - peer.addrs.len()); + /// Single attempt to add a peer (no retries) + pub(crate) async fn try_add_peer(&self, topic: &TopicId, peer: &EndpointAddr) -> Result<()> { + // Rule 4: Build relay-only address (strip direct IPs to prevent path migration) + let mut peer_addr = EndpointAddr { + id: peer.id, + addrs: peer.addrs.iter() + .filter(|a| matches!(a, TransportAddr::Relay(_))) + .cloned() + .collect(), + }; + + // Rule 5: If peer has no relay URL, inject ours (both use same N0 relay infrastructure) + if peer_addr.addrs.is_empty() { + let our_addr = self.endpoint.addr(); + for ta in &our_addr.addrs { + if let TransportAddr::Relay(url) = ta { + log_info!("[WEBXDC] add_peer: Peer {} has no relay URL, injecting ours: {}", peer.id, url); + peer_addr.addrs.insert(TransportAddr::Relay(url.clone())); + break; + } + } + } + + log_trace!("[WEBXDC] add_peer: Connecting to peer {}", peer_addr.id); + + // Connect to the peer and hand the connection to gossip + let conn = self.endpoint.connect(peer_addr, GOSSIP_ALPN).await?; + self.gossip.handle_connection(conn).await?; + + // Join the peer to the existing gossip topic + let channels = self.channels.read().await; + if let Some(channel_state) = channels.get(topic) { + channel_state.sender.join_peers(vec![peer.id]).await?; + log_info!("[WEBXDC] add_peer: Successfully joined peer {} to topic", peer.id); + } else { + return Err(anyhow!("Channel not found for topic")); + } + Ok(()) + } - let (event_target, peer_count) = { + /// Send data to a gossip topic (used by invoke fallback) + pub async fn send_data(&self, topic: TopicId, mut data: Vec) -> Result<()> { + let sender = { let channels = self.channels.read().await; - let channel = channels.get(topic) + let state = channels + .get(&topic) .ok_or_else(|| anyhow!("Channel not found for topic"))?; - (channel.event_target.clone(), channel.peer_count.clone()) - }; - connect_to_peer( - &self.endpoint, peer.clone(), *topic, - event_target, peer_count, - self.public_key_bytes, self.channels.clone(), - ).await?; + // Pre-allocate for trailer: 4-byte seq + 32-byte public key + data.reserve(TRAILER_LEN); + let seq_num = state.seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + data.extend_from_slice(&seq_num.to_le_bytes()); + data.extend_from_slice(&self.public_key_bytes); + + state.sender.clone() + }; - log_info!("[WEBXDC] add_peer: Successfully connected peer {} to topic", peer.id); + sender.broadcast(data.into()).await?; Ok(()) } - /// Zero-overhead send via the fast-path cache (entirely synchronous). - /// Called from the WS server and invoke fallback — no async, no JSON, no encoding. + /// Zero-overhead send via the fast-path cache (for WS server). + /// Adds gossip trailer and broadcasts via spawned task. pub fn fast_send(&self, label: &str, data: Vec) { - let handles = self.send_handles.read().unwrap_or_else(|e| e.into_inner()); + let handles = self.send_handles.read().unwrap_or_else(|e| { + log_error!("[WEBXDC] send_handles RwLock poisoned — recovering"); + e.into_inner() + }); let Some(handle) = handles.get(label) else { return }; - // Non-blocking enqueue — drops packet on overload - if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = handle.send_tx.try_send(data) { - handle.drops.fetch_add(1, Ordering::Relaxed); - } + // Add trailer: seq + pubkey + let mut msg = data; + msg.reserve(TRAILER_LEN); + let seq_num = handle.seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + msg.extend_from_slice(&seq_num.to_le_bytes()); + msg.extend_from_slice(&handle.public_key_bytes); + + // Fire-and-forget broadcast via a spawned task (broadcast is async) + let sender = handle.sender.clone(); + tokio::spawn(async move { + let _ = sender.broadcast(msg.into()).await; + }); } - /// Leave a realtime channel + /// Leave a realtime channel. + /// CRITICAL: All GossipSender/GossipReceiver clones MUST be dropped before + /// a new subscription to the same topic can work. Gossip only cleans up a topic + /// when ALL sender+receiver halves are dropped. If any clone survives, a subsequent + /// subscribe_with_opts to the same topic creates a broken duplicate subscription. pub async fn leave_channel(&self, topic: TopicId, label: &str) -> Result<()> { - // Remove fast-path send handle - self.send_handles.write().unwrap_or_else(|e| e.into_inner()).remove(label); + // 1. Remove fast-path SendHandle (drops its GossipSender clone) + self.send_handles.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }).remove(label); if let Some(channel) = self.channels.write().await.remove(&topic) { - // Abort drainer - channel.drainer_handle.abort(); - let _ = channel.drainer_handle.await; - - // Close all peer connections and abort their tasks - for (_key, peer) in channel.peers { - peer.conn.close(0u32.into(), b"leaving"); - peer.read_task.abort(); - peer.write_task.abort(); - } + // 2. Drop the ChannelState's sender explicitly (don't wait for implicit drop) + drop(channel.sender); + + // 3. Abort subscribe loop (drops GossipReceiver + sender_for_loop clone) + channel.subscribe_loop.abort(); + let _ = channel.subscribe_loop.await; + + // 4. Small yield to let the runtime fully clean up dropped tasks + tokio::task::yield_now().await; log_info!("Left realtime channel {:?}", topic); } @@ -460,14 +485,12 @@ impl IrohState { } } - /// Clear the event target for a topic (e.g., when the window is destroyed but - /// the Iroh channel is intentionally kept alive for peer count tracking). - /// Prevents the reader tasks from logging errors on every received message. + /// Clear the event target for a topic (prevents log errors after window close) pub async fn clear_event_target(&self, topic: &TopicId) { let channels = self.channels.read().await; if let Some(channel_state) = channels.get(topic) { - let mut target = channel_state.event_target.write().unwrap_or_else(|e| e.into_inner()); - *target = None; + let mut state = channel_state.event_target.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }); + state.target = None; } } @@ -475,11 +498,8 @@ impl IrohState { pub async fn has_channel(&self, topic: &TopicId) -> bool { self.channels.read().await.contains_key(topic) } - } -// ─── EventTarget / RealtimeEvent ──────────────────────────────────────────── - /// Target for delivering realtime events (abstracts desktop vs Android) #[derive(Clone)] pub enum EventTarget { @@ -489,45 +509,101 @@ pub enum EventTarget { MpscSender(tokio::sync::mpsc::Sender), } -/// Shared event target that can be updated when a user re-joins. -/// Uses std::sync::RwLock (not tokio) because the lock is held for <1μs -/// (just dispatching one event) and this avoids async runtime overhead -/// on every received message in the reader hot path. -pub(crate) type SharedEventTarget = Arc>>; +/// Shared event target + message buffer. When the target is None (preconnect created +/// the channel before JS attached a listener), incoming events are buffered. When the +/// target is set (joinRealtimeChannel), the buffer is flushed so no data is lost. +pub(crate) struct EventTargetState { + target: Option, + buffer: Vec, +} -/// Shared peer count that can be updated by reader tasks -pub(crate) type SharedPeerCount = Arc; +impl EventTargetState { + fn new(target: Option) -> Self { + Self { target, buffer: Vec::new() } + } -// ─── ChannelState ─────────────────────────────────────────────────────────── + /// Send an event, buffering if no target is set yet + fn send(&mut self, event: RealtimeEvent) -> bool { + if let Some(ref target) = self.target { + Self::deliver(target, event) + } else { + // Buffer up to 256 events (prevents unbounded memory if target is never set) + if self.buffer.len() < 256 { + self.buffer.push(event); + } + false + } + } -/// State for a single realtime channel (one per TopicId) + /// Set the target and flush all buffered events + fn set_target(&mut self, target: EventTarget) { + // Flush buffer + for event in self.buffer.drain(..) { + Self::deliver(&target, event); + } + self.target = Some(target); + } + + fn deliver(target: &EventTarget, event: RealtimeEvent) -> bool { + match target { + EventTarget::TauriChannel(channel) => { + if let Err(e) = channel.send(event) { + log_error!("[WEBXDC] Failed to send event to frontend: {e}"); + return false; + } + } + EventTarget::MpscSender(sender) => { + use tokio::sync::mpsc::error::TrySendError; + match sender.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + log_warn!("[WEBXDC] Event delivery backpressure, dropping message"); + } + Err(TrySendError::Closed(_)) => { + log_error!("[WEBXDC] Event delivery channel closed"); + return false; + } + } + } + } + true + } +} + +pub(crate) type SharedEventTarget = Arc>; + +/// Shared peer count that can be updated by the subscribe loop +pub(crate) type SharedPeerCount = Arc; + +/// State for a single gossip channel pub(crate) struct ChannelState { - /// Per-peer connections, keyed by remote PublicKey - peers: HashMap, - /// Handle to the fan-out drainer task - drainer_handle: JoinHandle<()>, - /// Bounded send queue — `try_send()` returns instantly, drainer fans out in background - send_tx: tokio::sync::mpsc::Sender>, + /// Handle to the subscribe loop task + subscribe_loop: JoinHandle<()>, + /// Sender for broadcasting messages + sender: GossipSender, /// Shared event target (can be updated on re-join) event_target: SharedEventTarget, /// Current number of connected peers peer_count: SharedPeerCount, - /// Dropped packet counter (overload detection, shared with SendHandle) - drops: Arc, + /// Sequence number for deduplication (lock-free) + seq: Arc, } impl std::fmt::Debug for ChannelState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ChannelState") - .field("peers", &self.peers.len()) - .field("drainer_handle", &"JoinHandle<()>") - .field("event_target", &"SharedEventTarget") .field("peer_count", &self.peer_count.load(Ordering::Relaxed)) - .field("drops", &self.drops.load(Ordering::Relaxed)) + .field("seq", &self.seq.load(Ordering::Relaxed)) .finish() } } +impl ChannelState { + fn new(subscribe_loop: JoinHandle<()>, sender: GossipSender, event_target: SharedEventTarget, peer_count: SharedPeerCount, seq: Arc) -> Self { + Self { subscribe_loop, sender, event_target, peer_count, seq } + } +} + /// Events sent to the frontend via Tauri channel #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase", tag = "event", content = "data")] @@ -540,41 +616,29 @@ pub enum RealtimeEvent { PeerJoined(String), /// A peer left the channel PeerLeft(String), - /// Messages were lost (app should request resync) + /// Gossip stream lagged — some messages were lost (app should request resync) Lagged, } -// ─── Event helpers ────────────────────────────────────────────────────────── - -/// Helper to send an event through the shared event target (sync — no async overhead) +/// Helper to send an event through the shared event target (sync — no async overhead). +/// Hot path uses read() lock (concurrent). Only upgrades to write() when buffering. fn send_event(shared_target: &SharedEventTarget, event: RealtimeEvent) -> bool { - let guard = shared_target.read().unwrap_or_else(|e| e.into_inner()); - if let Some(ref target) = *guard { - match target { - EventTarget::TauriChannel(channel) => { - if let Err(e) = channel.send(event) { - log_error!("[WEBXDC] Failed to send event to frontend: {e}"); - return false; - } - } - EventTarget::MpscSender(sender) => { - use tokio::sync::mpsc::error::TrySendError; - match sender.try_send(event) { - Ok(()) => {} - Err(TrySendError::Full(_)) => { - log_warn!("[WEBXDC] Event delivery backpressure, dropping message"); - } - Err(TrySendError::Closed(_)) => { - log_error!("[WEBXDC] Event delivery channel closed"); - return false; - } - } - } + // Fast path: try read lock first (concurrent, no contention) + { + let guard = shared_target.read().unwrap_or_else(|e| { + log_error!("[WEBXDC] SharedEventTarget RwLock poisoned — recovering"); + e.into_inner() + }); + if let Some(ref target) = guard.target { + return EventTargetState::deliver(target, event); } - true - } else { - false } + // Slow path: no target yet, take write lock to buffer + let mut guard = shared_target.write().unwrap_or_else(|e| { + log_error!("[WEBXDC] SharedEventTarget RwLock poisoned — recovering"); + e.into_inner() + }); + guard.send(event) } /// Emit realtime status update to the main window @@ -590,378 +654,98 @@ fn emit_realtime_status(app_handle: &Option, topic_encoded: &str, pee } } -// ─── Peer connection management ───────────────────────────────────────────── - -/// Handle an incoming peer connection from the accept loop. -/// The peer opens a bi stream and sends a 32-byte topic ID. -async fn handle_incoming_peer( - conn: iroh::endpoint::Connection, - channels: Arc>>, - our_key_bytes: [u8; PUBLIC_KEY_LENGTH], -) -> Result<()> { - let peer_key = conn.remote_id(); - - // Read topic ID from the control bi stream (with timeout to prevent stalling) - let (_ctrl_send, mut ctrl_recv) = tokio::time::timeout( - std::time::Duration::from_secs(5), - conn.accept_bi(), - ).await - .map_err(|_| anyhow!("Timeout waiting for topic handshake"))? - .map_err(|e| anyhow!("Failed to accept bi stream: {e}"))?; - - let mut topic_bytes = [0u8; 32]; - tokio::time::timeout( - std::time::Duration::from_secs(5), - ctrl_recv.read_exact(&mut topic_bytes), - ).await - .map_err(|_| anyhow!("Timeout reading topic ID"))? - .map_err(|e| anyhow!("Failed to read topic ID: {e}"))?; - - let topic = TopicId::from_bytes(topic_bytes); - log_info!("[WEBXDC] Incoming peer {} for topic {:?}", peer_key, topic); - - let mut channels_guard = channels.write().await; - let Some(channel) = channels_guard.get_mut(&topic) else { - conn.close(0u32.into(), b"unknown topic"); - bail!("Incoming peer for unknown topic {:?}", topic); - }; - - // Tie-breaker for simultaneous connections: if we already have a connection - // to this peer, the side with the higher public key wins (keeps their outgoing). - // We're the acceptor here (incoming connection), so we should only accept if - // the remote peer has the higher key (they're the rightful initiator). - if channel.peers.contains_key(&peer_key) { - if *peer_key.as_bytes() > our_key_bytes { - // Remote has higher key — they're the initiator, accept their connection - log_info!("[WEBXDC] Tie-breaker: accepting incoming from {} (higher key wins)", peer_key); - let old = channel.peers.remove(&peer_key).unwrap(); - old.conn.close(0u32.into(), b"tie-break"); - old.read_task.abort(); - old.write_task.abort(); - // Decrement peer count since we'll re-increment below - let _ = channel.peer_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| { - if c > 0 { Some(c - 1) } else { None } - }); - } else { - // We have higher key — we're the initiator, keep our outgoing connection - log_info!("[WEBXDC] Tie-breaker: rejecting incoming from {} (we have higher key)", peer_key); - conn.close(0u32.into(), b"tie-break"); - return Ok(()); - } - } - - // Spawn read/write tasks for this peer - let peer_conn = spawn_peer_tasks( - conn, - peer_key, - topic, - channel.event_target.clone(), - channel.peer_count.clone(), - channels.clone(), - ); - - // Increment peer count and emit events - let new_count = channel.peer_count.fetch_add(1, Ordering::Relaxed) + 1; - log_info!("[WEBXDC] Peer {} joined topic {:?} (now {} peers)", peer_key, topic, new_count); - let peer_str = base32_nopad_encode(peer_key.as_bytes()); - send_event(&channel.event_target, RealtimeEvent::PeerJoined(peer_str)); - - channel.peers.insert(peer_key, peer_conn); - - Ok(()) -} - -/// Connect to a peer: establish QUIC connection, send topic ID, spawn tasks. -/// -/// Tie-breaker: only the side with the higher public key initiates connections. -/// The lower-key side skips `connect_to_peer` and waits for the incoming connection -/// via the accept loop. This prevents simultaneous connections that cause -/// stream orphaning with persistent uni streams. -async fn connect_to_peer( - endpoint: &Endpoint, - addr: EndpointAddr, - topic: TopicId, - event_target: SharedEventTarget, - peer_count: SharedPeerCount, - our_key_bytes: [u8; PUBLIC_KEY_LENGTH], - channels: Arc>>, -) -> Result<()> { - // Tie-breaker: only initiate if we have the higher public key - let peer_id_bytes = addr.id.as_bytes(); - if our_key_bytes < *peer_id_bytes { - log_info!("[WEBXDC] Skipping outgoing connection to {} (they have higher key, they'll initiate)", addr.id); - return Ok(()); - } - - let conn = endpoint.connect(addr.clone(), VECTOR_RT_ALPN).await - .map_err(|e| anyhow!("Failed to connect to peer: {e}"))?; - let peer_key = conn.remote_id(); - - // Open control bi stream and send topic ID (topic handshake) - let (mut ctrl_send, _ctrl_recv) = conn.open_bi().await - .map_err(|e| anyhow!("Failed to open bi stream: {e}"))?; - ctrl_send.write_all(topic.as_bytes()).await - .map_err(|e| anyhow!("Failed to send topic ID: {e}"))?; - - let peer_conn = spawn_peer_tasks( - conn, peer_key, topic, - event_target.clone(), peer_count.clone(), - channels.clone(), - ); - - // Add to channel - let mut channels_guard = channels.write().await; - if let Some(channel) = channels_guard.get_mut(&topic) { - // Should not have an existing connection (we're the initiator), but handle gracefully - if let Some(old) = channel.peers.remove(&peer_key) { - old.conn.close(0u32.into(), b"replaced"); - old.read_task.abort(); - old.write_task.abort(); - } - - let new_count = channel.peer_count.fetch_add(1, Ordering::Relaxed) + 1; - log_info!("[WEBXDC] Connected to peer {} for topic {:?} (now {} peers)", peer_key, topic, new_count); - let peer_str = base32_nopad_encode(peer_key.as_bytes()); - send_event(&channel.event_target, RealtimeEvent::PeerJoined(peer_str)); - - channel.peers.insert(peer_key, peer_conn); - } else { - bail!("Channel removed before peer connection completed"); - } - - Ok(()) -} - -/// Spawn read and write tasks for a peer connection. -fn spawn_peer_tasks( - conn: iroh::endpoint::Connection, - peer_key: PublicKey, - topic: TopicId, - event_target: SharedEventTarget, - peer_count: SharedPeerCount, - channels: Arc>>, -) -> PeerConnection { - let (send_tx, send_rx) = tokio::sync::mpsc::channel::>(PEER_SEND_QUEUE_CAPACITY); - - let read_conn = conn.clone(); - let read_target = event_target; - let read_peer_count = peer_count; - let read_channels = channels; - let read_task = tokio::spawn(async move { - run_peer_reader(read_conn, peer_key, topic, read_target, read_peer_count, read_channels).await; - }); - - let write_conn = conn.clone(); - let write_task = tokio::spawn(async move { - run_peer_writer(write_conn, send_rx).await; - }); - - PeerConnection { - conn, - read_task, - write_task, - send_tx, - } -} - -// ─── Per-peer reader/writer tasks ─────────────────────────────────────────── - -/// Read varint-length-prefixed messages from a persistent uni stream and deliver to the event target. -/// No trailer needed — with raw QUIC, sender identity comes from the connection -/// itself and QUIC guarantees no duplicates. -async fn run_peer_reader( - conn: iroh::endpoint::Connection, - peer_key: PublicKey, +/// Run the subscribe loop for a gossip topic +async fn run_subscribe_loop( + mut receiver: GossipReceiver, + sender: GossipSender, topic: TopicId, shared_event_target: SharedEventTarget, + join_tx: oneshot::Sender<()>, + our_key_bytes: [u8; PUBLIC_KEY_LENGTH], peer_count: SharedPeerCount, - channels: Arc>>, -) { - // Accept the persistent uni stream from this peer's writer - let mut recv = match conn.accept_uni().await { - Ok(r) => r, - Err(_) => { - cleanup_peer(peer_key, &peer_count, &shared_event_target, &channels, &topic).await; - return; - } - }; - - let mut b0 = [0u8; 1]; - loop { - // Read varint length prefix: - // 0xxxxxxx = 1 byte (0–127) - // 10xxxxxx xxxxxxxx = 2 bytes (128–16,383) - // 11xxxxxx xxxxxxxx xxxxxxxx = 3 bytes (16,384–4,194,303) - match recv.read_exact(&mut b0).await { - Ok(()) => {} - Err(_) => break, // Stream/connection closed - } - let msg_len = if b0[0] & 0x80 == 0 { - b0[0] as usize - } else { - let mut extra = [0u8; 1]; - if recv.read_exact(&mut extra).await.is_err() { break; } - if b0[0] & 0xC0 == 0x80 { - ((b0[0] as usize & 0x3F) << 8) | extra[0] as usize - } else { - let mut extra2 = [0u8; 1]; - if recv.read_exact(&mut extra2).await.is_err() { break; } - ((b0[0] as usize & 0x3F) << 16) | ((extra[0] as usize) << 8) | extra2[0] as usize - } - }; - if msg_len == 0 { - continue; // Stream announcement packet — skip - } - if msg_len > MAX_MESSAGE_SIZE { - log_warn!("[WEBXDC] Peer {} sent oversized message: {} bytes", peer_key, msg_len); - break; - } - - // Read the message body - let mut content = vec![0u8; msg_len]; - match recv.read_exact(&mut content).await { - Ok(()) => {} - Err(e) => { - log_warn!("[WEBXDC] Failed to read message from peer {}: {e}", peer_key); - break; + app_handle: Option, + topic_encoded: String, +) -> Result<()> { + let mut join_tx = Some(join_tx); + log_info!("[WEBXDC] Subscribe loop started for topic {:?}", topic); + + while let Some(event) = receiver.next().await { + match event { + Ok(Event::Received(msg)) => { + let content = &msg.content; + if content.len() < TRAILER_LEN { + log_warn!("[WEBXDC] Dropping malformed message ({} bytes < {} trailer)", content.len(), TRAILER_LEN); + continue; + } + let payload_len = content.len() - TRAILER_LEN; + let sender_key = &content[payload_len + 4..]; + if sender_key == our_key_bytes { + continue; + } + send_event(&shared_event_target, RealtimeEvent::Data(base91_encode(&content[..payload_len]))); } - } - - // Deliver raw payload — no trailer to strip, no self-check needed - send_event(&shared_event_target, RealtimeEvent::Data(base91_encode(&content))); - } - - cleanup_peer(peer_key, &peer_count, &shared_event_target, &channels, &topic).await; -} - -/// Clean up after a peer disconnects. -async fn cleanup_peer( - peer_key: PublicKey, - peer_count: &SharedPeerCount, - shared_event_target: &SharedEventTarget, - channels: &Arc>>, - topic: &TopicId, -) { - log_info!("[WEBXDC] Peer disconnected: {}", peer_key); - let peer_str = base32_nopad_encode(peer_key.as_bytes()); - - // Decrement peer count (saturating to avoid underflow) - let _ = peer_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| { - if c > 0 { Some(c - 1) } else { None } - }); - - send_event(shared_event_target, RealtimeEvent::PeerLeft(peer_str)); - - // Remove from channel peers map - let mut channels_guard = channels.write().await; - if let Some(channel) = channels_guard.get_mut(topic) { - if let Some(peer) = channel.peers.remove(&peer_key) { - peer.write_task.abort(); - } - } -} - -/// Write varint-length-prefixed messages to a persistent uni stream. -/// Opens one stream and reuses it for all messages — eliminates per-message -/// stream handshake overhead (critical for relay-routed connections). -async fn run_peer_writer( - conn: iroh::endpoint::Connection, - mut rx: tokio::sync::mpsc::Receiver>, -) { - // Open the persistent send stream - let mut send = match conn.open_uni().await { - Ok(s) => s, - Err(e) => { - log_warn!("[WEBXDC] Failed to open send stream: {e}"); - return; - } - }; - - // Write a zero-length announcement to make the stream visible to the remote reader. - // QUIC doesn't notify the peer that a stream exists until data is written. - if let Err(e) = send.write_all(&[0u8]).await { - log_warn!("[WEBXDC] Failed to announce stream: {e}"); - return; - } - - while let Some(data) = rx.recv().await { - // Varint length prefix: 1 byte (0–127), 2 bytes (128–16,383), 3 bytes (16,384+) - let n = data.len(); - let (hdr, hdr_len) = if n < 128 { - ([n as u8, 0, 0], 1) - } else if n < 16384 { - ([0x80 | (n >> 8) as u8, n as u8, 0], 2) - } else { - ([0xC0 | (n >> 16) as u8, (n >> 8) as u8, n as u8], 3) - }; - if let Err(e) = send.write_all(&hdr[..hdr_len]).await { - log_warn!("[WEBXDC] Peer write failed (length): {e}"); - break; - } - if let Err(e) = send.write_all(&data).await { - log_warn!("[WEBXDC] Peer write failed (payload): {e}"); - break; - } - } - - // Gracefully close the stream - let _ = send.finish(); -} + Ok(Event::NeighborUp(peer_id)) => { + // Explicitly join this peer to our topic subscription. + // Fixes one-way data flow: when a peer connects via the Router, + // gossip has the connection but may not associate it with our topic. + if let Err(e) = sender.join_peers(vec![peer_id]).await { + log_warn!("[WEBXDC] Failed to join_peers on NeighborUp: {e}"); + } -// ─── Drainer ──────────────────────────────────────────────────────────────── + let new_count = peer_count.fetch_add(1, Ordering::Relaxed) + 1; + emit_realtime_status(&app_handle, &topic_encoded, new_count, true); -/// Drainer task: reads from the global send queue and fans out to all per-peer -/// send queues. Each peer has its own independent queue so one slow peer -/// cannot block others — the architectural solution to head-of-line blocking. -async fn run_send_drainer( - mut rx: tokio::sync::mpsc::Receiver>, - channels: Arc>>, - topic: TopicId, - drops: Arc, -) { - let mut drop_check = tokio::time::interval(std::time::Duration::from_secs(5)); - drop_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut batch = Vec::with_capacity(64); - - loop { - // Wait for at least one message (or drop-check tick) - tokio::select! { - msg = rx.recv() => { - match msg { - Some(data) => batch.push(data), - None => break, + if let Some(tx) = join_tx.take() { + let _ = tx.send(()); + send_event(&shared_event_target, RealtimeEvent::Connected); } + let peer_str = base32_nopad_encode(peer_id.as_bytes()); + send_event(&shared_event_target, RealtimeEvent::PeerJoined(peer_str)); } - _ = drop_check.tick() => { - let dropped = drops.swap(0, Ordering::Relaxed); - if dropped > 0 { - log_warn!("[WEBXDC] Realtime overload: {dropped} packets dropped in last 5s"); + Ok(Event::NeighborDown(peer_id)) => { + let peer_str = base32_nopad_encode(peer_id.as_bytes()); + + let _ = peer_count.fetch_update( + Ordering::Relaxed, Ordering::Relaxed, + |count| if count > 0 { Some(count - 1) } else { None }, + ); + let new_count = peer_count.load(Ordering::Relaxed); + emit_realtime_status(&app_handle, &topic_encoded, new_count, true); + send_event(&shared_event_target, RealtimeEvent::PeerLeft(peer_str)); + + // Auto-reconnect: re-advertise via Nostr after a short delay + if let Some(ref app) = app_handle { + let app = app.clone(); + let te = topic_encoded.clone(); + let topic_copy = topic; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let state = app.state::(); + if !state.has_realtime_channel_for_topic(&topic_copy).await { return; } + if let Ok(iroh) = state.realtime.get_or_init().await { + let node_addr = iroh.get_node_addr(); + if let Ok(addr_encoded) = encode_node_addr(&node_addr) { + if let Some(chat_id) = state.get_chat_id_for_topic(&topic_copy).await { + log_info!("[WEBXDC] NeighborDown: re-advertising for reconnection (topic: {})", te); + crate::commands::realtime::send_webxdc_peer_advertisement(chat_id, te, addr_encoded).await; + } + } + } + }); } - continue; } - } - - // Drain all immediately-available messages (non-blocking) - while batch.len() < 64 { - match rx.try_recv() { - Ok(data) => batch.push(data), - Err(_) => break, + Ok(Event::Lagged) => { + log_warn!("[WEBXDC] Gossip lagged for topic {:?}", topic); + send_event(&shared_event_target, RealtimeEvent::Lagged); } - } - - // Fan-out: send each message to every peer's individual queue (non-blocking) - let channels_guard = channels.read().await; - if let Some(channel) = channels_guard.get(&topic) { - for data in batch.drain(..) { - for (_peer_key, peer_conn) in &channel.peers { - // Non-blocking: if this peer's queue is full, they're slow — skip them - let _ = peer_conn.send_tx.try_send(data.clone()); - } + Err(e) => { + log_error!("[WEBXDC] Gossip error for topic {:?}: {e}", topic); } - } else { - batch.clear(); } } + + log_info!("[WEBXDC] Subscribe loop ended for topic {:?}", topic); + Ok(()) } // ─── RealtimeManager ──────────────────────────────────────────────────────── @@ -975,8 +759,11 @@ pub struct RealtimeManager { iroh: tokio::sync::OnceCell, /// Custom relay URL (if any) relay_url: Option, - /// WebSocket server info (port + token), set once after Iroh init + /// WebSocket server info (port + token), set once after WS server starts ws_info: std::sync::OnceLock, + /// Fast-path send handles — owned here so the WS server can start + /// before IrohState exists (critical for Android JNI timing). + send_handles: Arc>>, } impl RealtimeManager { @@ -985,6 +772,7 @@ impl RealtimeManager { iroh: tokio::sync::OnceCell::new(), relay_url, ws_info: std::sync::OnceLock::new(), + send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())), } } @@ -992,29 +780,71 @@ impl RealtimeManager { /// After first call, this is a single atomic load (~5ns). /// Also starts the realtime WebSocket server on first init. pub async fn get_or_init(&self) -> Result<&IrohState> { + let sh = self.send_handles.clone(); let iroh = self.iroh - .get_or_try_init(|| IrohState::new(self.relay_url.clone())) + .get_or_try_init(|| IrohState::new(self.relay_url.clone(), sh)) .await?; - // Start the WS server once (after IrohState is available) - if self.ws_info.get().is_none() { - match super::rt_ws::start( - iroh.send_handles.clone(), - ).await { - Ok(info) => { - let _ = self.ws_info.set(info); - } - Err(e) => { - log_warn!("[WEBXDC] Failed to start RT WS server: {e}"); - } - } - } + // Start WS server if not already running + self.ensure_ws_started(); Ok(iroh) } + /// Start the WS server if not already running. + /// Uses std::net::TcpListener for sync bind (no async runtime needed), + /// then spawns the accept loop on tauri::async_runtime so it survives + /// any temporary runtime (critical for Android JNI). + pub fn ensure_ws_started(&self) { + if self.ws_info.get().is_some() { + return; + } + + // Sync bind — no runtime needed + let std_listener = match std::net::TcpListener::bind("127.0.0.1:0") { + Ok(l) => l, + Err(e) => { + log_warn!("[WEBXDC] Failed to bind RT WS server: {e}"); + return; + } + }; + let port = match std_listener.local_addr() { + Ok(a) => a.port(), + Err(e) => { + log_warn!("[WEBXDC] Failed to get RT WS local addr: {e}"); + return; + } + }; + std_listener.set_nonblocking(true).ok(); + + // Random 32-char hex token (128-bit security) + let token = { + let mut bytes = [0u8; 16]; + use rand::RngCore; + rand::rngs::OsRng.fill_bytes(&mut bytes); + crate::simd::hex::bytes_to_hex_16(&bytes) + }; + + log_info!("[WEBXDC] Realtime WS server listening on 127.0.0.1:{port}"); + + let _ = self.ws_info.set(super::rt_ws::WsInfo { port, token: token.clone() }); + + // Spawn accept loop on the MAIN Tauri runtime (survives JNI temp runtime) + let send_handles = self.send_handles.clone(); + tauri::async_runtime::spawn(async move { + // Convert std listener to tokio listener on the main runtime + let listener = match tokio::net::TcpListener::from_std(std_listener) { + Ok(l) => l, + Err(e) => { + log_error!("[WEBXDC] Failed to convert RT WS listener to tokio: {e}"); + return; + } + }; + super::rt_ws::run_accept_loop(listener, token, send_handles).await; + }); + } + /// Get the WebSocket URL for the realtime fast path, if the server is running. - /// Returns `ws://127.0.0.1:{port}/{token}`. pub fn ws_url(&self) -> Option { self.ws_info.get().map(|info| { format!("ws://127.0.0.1:{}/{}", info.port, info.token) @@ -1036,7 +866,7 @@ impl Default for RealtimeManager { } } -// ─── Topic ID helpers ─────────────────────────────────────────────────────── +// ─── Topic utilities ───────────────────────────────────────────────────────── /// Generate a random topic ID (for testing/fallback only) #[allow(dead_code)] @@ -1047,9 +877,6 @@ pub fn generate_topic_id() -> TopicId { } /// Derive a deterministic topic ID from file hash, chat context, and message ID -/// This ensures all participants viewing the same Mini App message -/// will derive the same topic ID without needing to transmit it. -/// Including message_id ensures reposts create isolated instances. pub fn derive_topic_id(file_hash: &str, chat_id: &str, message_id: &str) -> TopicId { use sha2::{Sha256, Digest}; @@ -1101,22 +928,14 @@ pub fn decode_node_addr(s: &str) -> Result { Ok(addr) } -/// Check if an IP address is a LAN/private address (safe to share without leaking public IP). -/// -/// Includes: -/// - IPv4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918 private) -/// - IPv4: 169.254.0.0/16 (link-local), 127.0.0.0/8 (loopback) -/// - IPv6: ::1 (loopback), fe80::/10 (link-local), fc00::/7 (ULA) +/// Check if an IP address is a LAN/private address fn is_lan_addr(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(), std::net::IpAddr::V6(v6) => { v6.is_loopback() || { let seg0 = v6.segments()[0]; - // fe80::/10 — link-local - (seg0 & 0xffc0) == 0xfe80 || - // fc00::/7 — unique local address (ULA) - (seg0 & 0xfe00) == 0xfc00 + (seg0 & 0xffc0) == 0xfe80 || (seg0 & 0xfe00) == 0xfc00 } } } @@ -1137,27 +956,14 @@ mod tests { #[test] fn test_is_lan_addr() { use std::net::IpAddr; - - // IPv4 private ranges — should pass assert!(is_lan_addr(&"192.168.1.1".parse::().unwrap())); assert!(is_lan_addr(&"10.0.0.1".parse::().unwrap())); assert!(is_lan_addr(&"172.16.0.1".parse::().unwrap())); - assert!(is_lan_addr(&"172.31.255.255".parse::().unwrap())); assert!(is_lan_addr(&"127.0.0.1".parse::().unwrap())); - assert!(is_lan_addr(&"169.254.1.1".parse::().unwrap())); - - // IPv4 public — should fail assert!(!is_lan_addr(&"8.8.8.8".parse::().unwrap())); assert!(!is_lan_addr(&"1.1.1.1".parse::().unwrap())); - assert!(!is_lan_addr(&"203.0.113.1".parse::().unwrap())); - - // IPv6 private — should pass assert!(is_lan_addr(&"::1".parse::().unwrap())); assert!(is_lan_addr(&"fe80::1".parse::().unwrap())); - assert!(is_lan_addr(&"fd12:3456:789a::1".parse::().unwrap())); - - // IPv6 public — should fail assert!(!is_lan_addr(&"2001:db8::1".parse::().unwrap())); - assert!(!is_lan_addr(&"2607:f8b0:4004:800::200e".parse::().unwrap())); } } diff --git a/src-tauri/src/miniapps/rt_ws.rs b/src-tauri/src/miniapps/rt_ws.rs index 40186d96..3d40eb1a 100644 --- a/src-tauri/src/miniapps/rt_ws.rs +++ b/src-tauri/src/miniapps/rt_ws.rs @@ -27,55 +27,36 @@ struct WsState { send_handles: Arc>>, } -/// Start the realtime WebSocket server on a random localhost port. -/// -/// Returns `(port, token)`. The server runs as a background tokio task. -pub(crate) async fn start( - send_handles: Arc>>, -) -> Result { - // Random 32-char hex token (128-bit security) - let token = { - let mut bytes = [0u8; 16]; - use rand::RngCore; - rand::rngs::OsRng.fill_bytes(&mut bytes); - crate::simd::hex::bytes_to_hex_16(&bytes) - }; - let listener = TcpListener::bind("127.0.0.1:0") - .await - .map_err(|e| format!("Failed to bind RT WS server: {e}"))?; - let port = listener - .local_addr() - .map_err(|e| format!("Failed to get local addr: {e}"))? - .port(); - - log_info!("[WEBXDC] Realtime WS server listening on 127.0.0.1:{port}"); +/// Run the WS accept loop. Public so RealtimeManager can call it directly +/// with a pre-bound listener (needed for Android JNI timing). +pub(crate) async fn run_accept_loop( + listener: TcpListener, + token: String, + send_handles: Arc>>, +) { let state = Arc::new(WsState { - token: token.clone(), + token, send_handles, }); - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, _addr)) => { - let st = Arc::clone(&state); - tokio::spawn(async move { - if let Err(e) = handle_connection(stream, &st).await { - log_trace!("[WEBXDC] RT WS connection error: {e}"); - } - }); - } - Err(e) => { - log_warn!("[WEBXDC] RT WS accept error: {e}"); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let st = Arc::clone(&state); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, &st).await { + log_trace!("[WEBXDC] RT WS connection error: {e}"); + } + }); + } + Err(e) => { + log_warn!("[WEBXDC] RT WS accept error: {e}"); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } } - }); - - Ok(WsInfo { port, token }) + } } /// Handle a single WebSocket connection. @@ -192,9 +173,7 @@ async fn handle_connection( Ok(()) } -/// Inline fast_send — forwards raw payload directly to the send queue. -/// No trailer needed: raw QUIC connections identify senders by connection -/// and guarantee no duplicates, so seq/pubkey trailers are unnecessary. +/// Inline fast_send — adds gossip trailer and broadcasts via gossip protocol. #[inline] fn fast_send_inline( send_handles: &std::sync::RwLock>, @@ -206,10 +185,18 @@ fn fast_send_inline( return; }; - // Non-blocking enqueue — drops packet on overload - if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = handle.send_tx.try_send(data) { - handle.drops.fetch_add(1, Ordering::Relaxed); - } + // Add trailer: seq(4) + pubkey(32) + let mut msg = data; + msg.reserve(36); + let seq_num = handle.seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + msg.extend_from_slice(&seq_num.to_le_bytes()); + msg.extend_from_slice(&handle.public_key_bytes); + + // Fire-and-forget broadcast (gossip is async, spawn a task) + let sender = handle.sender.clone(); + tokio::spawn(async move { + let _ = sender.broadcast(msg.into()).await; + }); } /// Simple percent-decode for URL path segments. diff --git a/src-tauri/src/miniapps/scheme.rs b/src-tauri/src/miniapps/scheme.rs index 8e408385..3e3fa9c6 100644 --- a/src-tauri/src/miniapps/scheme.rs +++ b/src-tauri/src/miniapps/scheme.rs @@ -555,11 +555,13 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String // Fast path: WebSocket binary frame (persistent TCP, ~1μs per msg) if (rtWs && rtWs.readyState === 1) {{ rtWs.send(buf); - }} else if (rtWsFailed && window.__TAURI__) {{ - // Fallback: Tauri invoke (for Linux/WebKitGTK where WS from - // custom scheme origins silently hangs) - window.__TAURI__.core.invoke('miniapp_send_realtime_data', {{ - data: Array.from(buf) + }} else {{ + // Fallback: Tauri invoke via waitForTauri (queues until __TAURI__ is injected). + // On Android, __TAURI__ is injected asynchronously — direct checks fail. + waitForTauri(function() {{ + window.__TAURI__.core.invoke('miniapp_send_realtime_data', {{ + data: Array.from(buf) + }}); }}); }} }}, @@ -607,6 +609,9 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String rtWs = null; rtWsFailed = true; }} + }} else {{ + console.warn('[webxdc] No ws_url returned — using invoke fallback'); + rtWsFailed = true; }} }}).catch(function(err) {{ console.error('[webxdc] Failed to join realtime channel:', err); diff --git a/src-tauri/src/miniapps/state.rs b/src-tauri/src/miniapps/state.rs index 1827d9db..f29d5eff 100644 --- a/src-tauri/src/miniapps/state.rs +++ b/src-tauri/src/miniapps/state.rs @@ -189,6 +189,35 @@ impl MiniAppPackage { self.get_file(&self.manifest.icon).ok() } } + + /// Check if an XDC package's source uses the WebXDC realtime channel API. + /// Scans HTML/JS files for `joinRealtimeChannel`. Designed for `spawn_blocking`. + pub fn scan_for_realtime_api(path: &std::path::Path) -> bool { + let file = match std::fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut archive = match zip::ZipArchive::new(file) { + Ok(a) => a, + Err(_) => return false, + }; + const NEEDLE: &[u8] = b"joinRealtimeChannel"; + for i in 0..archive.len() { + let Ok(mut entry) = archive.by_index(i) else { continue }; + let name = entry.name().to_lowercase(); + if !(name.ends_with(".html") || name.ends_with(".htm") + || name.ends_with(".js") || name.ends_with(".mjs")) + { + continue; + } + let mut buf = Vec::new(); + if entry.read_to_end(&mut buf).is_err() { continue; } + if buf.windows(NEEDLE.len()).any(|w| w == NEEDLE) { + return true; + } + } + false + } } /// Represents a running Mini App instance @@ -216,13 +245,6 @@ pub struct RealtimeChannelState { } /// Global state for managing Mini App instances -/// A pending peer advertisement (received before we joined the channel) -#[derive(Clone, Debug)] -pub struct PendingPeer { - pub node_addr: iroh::EndpointAddr, - pub received_at: std::time::Instant, -} - pub struct MiniAppsState { /// Map of window_label -> MiniAppInstance instances: RwLock>, @@ -232,9 +254,15 @@ pub struct MiniAppsState { pub realtime: RealtimeManager, /// Map of window_label -> realtime channel state pub realtime_channels: RwLock>, - /// Pending peer advertisements (topic -> list of peers) - /// These are peers that advertised before we joined the channel - pub pending_peers: RwLock>>, + /// Cached peer addresses for QUIC connection on join (topic -> addrs). + /// Populated from peer advertisements, consumed on join. + peer_addrs: RwLock>>, + /// Known session participants (topic -> list of npubs). + /// Single source of truth for lobby state and avatar display. + session_peers: RwLock>>, + /// Preconnect completion signals — joinRealtimeChannel awaits these + /// before attaching the event listener. Sender lives in the preconnect task. + preconnect_signals: RwLock>>, } impl MiniAppsState { @@ -244,7 +272,9 @@ impl MiniAppsState { packages: RwLock::new(HashMap::new()), realtime: RealtimeManager::new(None), realtime_channels: RwLock::new(HashMap::new()), - pending_peers: RwLock::new(HashMap::new()), + peer_addrs: RwLock::new(HashMap::new()), + session_peers: RwLock::new(HashMap::new()), + preconnect_signals: RwLock::new(HashMap::new()), } } @@ -267,85 +297,92 @@ impl MiniAppsState { } /// Check if an instance has an active realtime channel + #[cfg_attr(not(target_os = "android"), allow(dead_code))] pub async fn has_realtime_channel(&self, window_label: &str) -> bool { let channels = self.realtime_channels.read().await; channels.get(window_label).map(|s| s.active).unwrap_or(false) } - - /// Add a pending peer for a topic (received before we joined) - pub async fn add_pending_peer(&self, topic: TopicId, node_addr: iroh::EndpointAddr) { - let topic_encoded = crate::miniapps::realtime::encode_topic_id(&topic); - println!("[WEBXDC] add_pending_peer: Adding peer {} for topic {}", node_addr.id, topic_encoded); - - let mut pending = self.pending_peers.write().await; - let peers = pending.entry(topic).or_insert_with(Vec::new); - - // Don't add duplicates - if !peers.iter().any(|p| p.node_addr.id == node_addr.id) { - peers.push(PendingPeer { - node_addr: node_addr.clone(), - received_at: std::time::Instant::now(), - }); - println!("[WEBXDC] add_pending_peer: Stored pending peer {} for topic {}", node_addr.id, topic_encoded); - } else { - println!("[WEBXDC] add_pending_peer: Peer {} already exists for topic {}", node_addr.id, topic_encoded); - } + + /// Check if ANY instance has an active realtime channel for a given topic + pub async fn has_realtime_channel_for_topic(&self, topic: &TopicId) -> bool { + let channels = self.realtime_channels.read().await; + channels.values().any(|s| s.active && &s.topic == topic) + } + + /// Get the chat_id for an instance that has a realtime channel with the given topic + pub async fn get_chat_id_for_topic(&self, topic: &TopicId) -> Option { + let channels = self.realtime_channels.read().await; + let label = channels.iter() + .find(|(_, s)| s.active && &s.topic == topic) + .map(|(label, _)| label.clone())?; + drop(channels); + self.get_instance(&label).await.map(|i| i.chat_id.clone()) } - /// Get and clear pending peers for a topic - pub async fn take_pending_peers(&self, topic: &TopicId) -> Vec { - let topic_encoded = crate::miniapps::realtime::encode_topic_id(topic); - println!("[WEBXDC] take_pending_peers: Looking for peers for topic {}", topic_encoded); - - let mut pending = self.pending_peers.write().await; - - // Debug: print all pending topics - println!("[WEBXDC] take_pending_peers: Currently have {} topics with pending peers", pending.len()); - for (t, peers) in pending.iter() { - let t_encoded = crate::miniapps::realtime::encode_topic_id(t); - println!("[WEBXDC] take_pending_peers: Topic {} has {} pending peers", t_encoded, peers.len()); + // ─── Preconnect signals ─────────────────────────────────────────────── + + /// Store a preconnect completion signal for a window label + pub async fn set_preconnect_signal(&self, label: &str, rx: tokio::sync::watch::Receiver) { + self.preconnect_signals.write().await.insert(label.to_string(), rx); + } + + /// Take the preconnect signal for a window label (consumed by joinRealtimeChannel) + pub async fn take_preconnect_signal(&self, label: &str) -> Option> { + self.preconnect_signals.write().await.remove(label) + } + + // ─── Peer address cache (for QUIC connection on join) ────────────────── + + /// Cache a peer's address for a topic (from a peer advertisement) + pub async fn cache_peer_addr(&self, topic: TopicId, addr: iroh::EndpointAddr) { + let mut addrs = self.peer_addrs.write().await; + let list = addrs.entry(topic).or_default(); + if !list.iter().any(|a| a.id == addr.id) { + list.push(addr); } - - let peers = pending.remove(topic).unwrap_or_default(); - println!("[WEBXDC] take_pending_peers: Found {} peers for topic {}", peers.len(), topic_encoded); - - // Filter out peers that are too old (more than 5 minutes) - let now = std::time::Instant::now(); - let filtered: Vec = peers.into_iter() - .filter(|p| now.duration_since(p.received_at).as_secs() < 300) - .collect(); - - println!("[WEBXDC] take_pending_peers: After filtering, {} peers remain", filtered.len()); - filtered } - - /// Get the count of pending peers for a topic (without removing them) - pub async fn get_pending_peer_count(&self, topic: &TopicId) -> usize { - let pending = self.pending_peers.read().await; - pending.get(topic).map(|peers| peers.len()).unwrap_or(0) + + /// Take all cached peer addresses for a topic (consumed on join) + pub async fn take_peer_addrs(&self, topic: &TopicId) -> Vec { + let mut addrs = self.peer_addrs.write().await; + addrs.remove(topic).unwrap_or_default() } - - /// Clean up expired pending peers (older than 5 minutes) - /// This should be called periodically to prevent memory leaks - pub async fn cleanup_expired_pending_peers(&self) { - let now = std::time::Instant::now(); - let mut pending = self.pending_peers.write().await; - - // Remove expired peers from each topic - pending.retain(|_topic, peers| { - let before_count = peers.len(); - peers.retain(|p| now.duration_since(p.received_at).as_secs() < 300); - let after_count = peers.len(); - - if before_count != after_count { - log_debug!("[WEBXDC] Cleaned up {} expired peers for topic {}", - before_count - after_count, crate::miniapps::realtime::encode_topic_id(_topic)); + + // ─── Session peers (persistent participant tracking) ─────────────────── + + /// Add a participant npub to the session (idempotent) + pub async fn add_session_peer(&self, topic: TopicId, npub: String) { + let mut peers = self.session_peers.write().await; + let list = peers.entry(topic).or_default(); + if !list.contains(&npub) { + list.push(npub); + } + } + + /// Remove a participant npub from the session + pub async fn remove_session_peer(&self, topic: &TopicId, npub: &str) { + let mut peers = self.session_peers.write().await; + if let Some(list) = peers.get_mut(topic) { + list.retain(|n| n != npub); + if list.is_empty() { + peers.remove(topic); } - - // Keep the topic entry only if it still has peers - !peers.is_empty() - }); + } } + + /// Get all participant npubs for a session + pub async fn get_session_peers(&self, topic: &TopicId) -> Vec { + let peers = self.session_peers.read().await; + peers.get(topic).cloned().unwrap_or_default() + } + + /// Clear all session peers for a topic + #[allow(dead_code)] + pub async fn clear_session_peers(&self, topic: &TopicId) { + let mut peers = self.session_peers.write().await; + peers.remove(topic); + } + /// Register a new Mini App instance pub async fn add_instance(&self, instance: MiniAppInstance) { diff --git a/src-tauri/src/mls/mod.rs b/src-tauri/src/mls/mod.rs index cd605dac..13ff9583 100644 --- a/src-tauri/src/mls/mod.rs +++ b/src-tauri/src/mls/mod.rs @@ -2488,9 +2488,11 @@ impl MlsService { println!("[MLS] Not admin, ignoring leave request from {}", member_pubkey); } } - RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr } => { - // Handle WebXDC peer advertisement - add peer to realtime channel - crate::services::handle_webxdc_peer_advertisement(&topic_id, &node_addr).await; + RumorProcessingResult::WebxdcPeerAdvertisement { event_id, topic_id, node_addr, sender_npub, created_at } => { + crate::services::handle_webxdc_peer_advertisement(&event_id, &topic_id, &node_addr, &sender_npub, created_at, &chat_id).await; + } + RumorProcessingResult::WebxdcPeerLeft { event_id, topic_id, sender_npub, created_at } => { + crate::services::handle_webxdc_peer_left(&event_id, &topic_id, &sender_npub, created_at, &chat_id).await; } RumorProcessingResult::UnknownEvent(mut event) => { // Store unknown events for future compatibility diff --git a/src-tauri/src/rumor.rs b/src-tauri/src/rumor.rs index b4a17d51..a540b905 100644 --- a/src-tauri/src/rumor.rs +++ b/src-tauri/src/rumor.rs @@ -99,8 +99,18 @@ pub enum RumorProcessingResult { }, /// A WebXDC peer advertisement for realtime channels WebxdcPeerAdvertisement { + event_id: String, topic_id: String, node_addr: String, + sender_npub: String, + created_at: u64, + }, + /// A WebXDC peer left signal (peer closed their Mini App) + WebxdcPeerLeft { + event_id: String, + topic_id: String, + sender_npub: String, + created_at: u64, }, /// Unknown event type - stored for future compatibility /// The frontend will render this as "Unknown Event" placeholder @@ -904,29 +914,39 @@ async fn process_app_specific( .ok_or("Peer advertisement missing webxdc-node-addr tag")? .to_string(); - // Validate expiration (peer advertisements should be short-lived) - if let Some(expiry_tag) = rumor.tags.find(TagKind::Expiration) { - if let Some(expiry_str) = expiry_tag.content() { - if let Ok(expiry_timestamp) = expiry_str.parse::() { - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("System time error: {}", e))? - .as_secs(); - - // Reject expired advertisements - if expiry_timestamp <= current_timestamp { - return Ok(RumorProcessingResult::Ignored); - } - } - } - } - + let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default(); return Ok(RumorProcessingResult::WebxdcPeerAdvertisement { + event_id: rumor.id.to_hex(), topic_id, node_addr, + sender_npub, + created_at: rumor.created_at.as_secs(), }); } + // Check if this is a WebXDC peer-left signal + if is_webxdc_peer_left(&rumor) { + if context.is_mine { + return Ok(RumorProcessingResult::Ignored); + } + + log_info!("[WEBXDC] Detected peer-left signal from another device"); + + let topic_id = rumor.tags + .find(TagKind::Custom(std::borrow::Cow::Borrowed("webxdc-topic"))) + .and_then(|tag| tag.content()) + .ok_or("Peer-left missing webxdc-topic tag")? + .to_string(); + + let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default(); + return Ok(RumorProcessingResult::WebxdcPeerLeft { + event_id: rumor.id.to_hex(), + topic_id, + sender_npub, + created_at: rumor.created_at.as_secs(), + }); + } + // Unknown application-specific data Ok(RumorProcessingResult::Ignored) } @@ -938,6 +958,12 @@ fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool { && rumor.tags.find(TagKind::Custom(std::borrow::Cow::Borrowed("webxdc-node-addr"))).is_some() } +/// Check if a rumor is a WebXDC peer-left signal +fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool { + rumor.content == "peer-left" + && rumor.tags.find(TagKind::Custom(std::borrow::Cow::Borrowed("webxdc-topic"))).is_some() +} + /// Check if a rumor is a PIVX payment fn is_pivx_payment(rumor: &RumorEvent) -> bool { rumor.tags diff --git a/src-tauri/src/services/event_handler.rs b/src-tauri/src/services/event_handler.rs index 0b54f065..f58536b3 100644 --- a/src-tauri/src/services/event_handler.rs +++ b/src-tauri/src/services/event_handler.rs @@ -291,9 +291,11 @@ pub(crate) async fn handle_event_with_context( // Leave requests only apply to MLS groups, not DMs false // Administrative — not a new message } - RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr } => { - // Handle WebXDC peer advertisement - add peer to realtime channel - handle_webxdc_peer_advertisement(&topic_id, &node_addr).await + RumorProcessingResult::WebxdcPeerAdvertisement { event_id, topic_id, node_addr, sender_npub, created_at } => { + handle_webxdc_peer_advertisement(&event_id, &topic_id, &node_addr, &sender_npub, created_at, &contact).await + } + RumorProcessingResult::WebxdcPeerLeft { event_id, topic_id, sender_npub, created_at } => { + handle_webxdc_peer_left(&event_id, &topic_id, &sender_npub, created_at, &contact).await } RumorProcessingResult::UnknownEvent(mut event) => { // Store unknown events for future compatibility @@ -852,8 +854,11 @@ pub(crate) async fn commit_prepared_event(prepared: PreparedEvent, is_new: bool) false // Ephemeral — not a new message } RumorProcessingResult::LeaveRequest { .. } => false, // Administrative — not a new message - RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr } => { - handle_webxdc_peer_advertisement(&topic_id, &node_addr).await + RumorProcessingResult::WebxdcPeerAdvertisement { event_id, topic_id, node_addr, sender_npub, created_at } => { + handle_webxdc_peer_advertisement(&event_id, &topic_id, &node_addr, &sender_npub, created_at, &contact).await + } + RumorProcessingResult::WebxdcPeerLeft { event_id, topic_id, sender_npub, created_at } => { + handle_webxdc_peer_left(&event_id, &topic_id, &sender_npub, created_at, &contact).await } RumorProcessingResult::UnknownEvent(mut event) => { event.wrapper_event_id = Some(wrapper_event_id.clone()); @@ -1034,11 +1039,52 @@ pub(crate) async fn commit_prepared_event(prepared: PreparedEvent, is_new: bool) } } -/// Handle a WebXDC peer advertisement - add the peer to our realtime channel -pub(crate) async fn handle_webxdc_peer_advertisement(topic_id: &str, node_addr_encoded: &str) -> bool { +/// Handle a WebXDC peer advertisement - persist to SQLite and add the peer to our realtime channel +pub(crate) async fn handle_webxdc_peer_advertisement( + event_id: &str, + topic_id: &str, + node_addr_encoded: &str, + sender_npub: &str, + created_at: u64, + conversation_id: &str, +) -> bool { use crate::miniapps::realtime::{decode_topic_id, decode_node_addr}; - + log_info!("[WEBXDC] Received peer advertisement for topic {}", topic_id); + + // Persist to SQLite for offline→online peer discovery + if !db::event_exists(event_id).unwrap_or(true) { + if let Ok(chat_id) = db::get_or_create_chat_id(conversation_id) { + let tags = vec![ + vec!["webxdc-topic".to_string(), topic_id.to_string()], + vec!["webxdc-node-addr".to_string(), node_addr_encoded.to_string()], + vec!["d".to_string(), "vector-webxdc-peer".to_string()], + ]; + let event = crate::stored_event::StoredEvent { + id: event_id.to_string(), + kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC, + chat_id, + user_id: None, + content: "peer-advertisement".to_string(), + tags, + reference_id: Some(topic_id.to_string()), + created_at, + received_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + mine: false, + pending: false, + failed: false, + wrapper_event_id: None, + npub: Some(sender_npub.to_string()), + preview_metadata: None, + }; + if let Err(e) = db::save_event(&event).await { + log_warn!("[WEBXDC] Failed to persist peer advertisement: {}", e); + } + } + } // Decode the topic ID let topic = match decode_topic_id(topic_id) { @@ -1080,6 +1126,7 @@ pub(crate) async fn handle_webxdc_peer_advertisement(topic_id: &str, node_addr_e if has_channel { log_info!("[WEBXDC] Found active channel for topic {}, adding peer", topic_id); + state.add_session_peer(topic, sender_npub.to_string()).await; // Get the realtime manager and add the peer match state.realtime.get_or_init().await { Ok(iroh) => { @@ -1087,7 +1134,6 @@ pub(crate) async fn handle_webxdc_peer_advertisement(topic_id: &str, node_addr_e Ok(_) => { log_info!("[WEBXDC] Successfully added peer {} to realtime channel topic {}", node_addr.id, topic_id); - return true; } Err(e) => { log_error!("[WEBXDC] Failed to add peer to realtime channel: {}", e); @@ -1098,27 +1144,133 @@ pub(crate) async fn handle_webxdc_peer_advertisement(topic_id: &str, node_addr_e log_error!("[WEBXDC] Failed to get realtime manager: {}", e); } } + + // Emit status update so the frontend shows the new peer's avatar + let peer_npubs = state.get_session_peers(&topic).await; + let peer_count = peer_npubs.len(); + if let Some(main_window) = handle.get_webview_window("main") { + use tauri::Emitter; + let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ + "topic": topic_id, + "peer_count": peer_count, + "peers": peer_npubs, + "is_active": true, + "has_pending_peers": true, + })); + log_info!("[WEBXDC] Emitted miniapp_realtime_status: topic={}, peer_count={}", topic_id, peer_count); + } + return true; } else { - // Store as pending peer - we'll add them when we join the channel - log_info!("[WEBXDC] Storing pending peer for topic {} (no active channel yet)", topic_id); - state.add_pending_peer(topic, node_addr).await; - - // Emit event to frontend so it can update the UI (show "Click to Join" and player count) - let pending_count = state.get_pending_peer_count(&topic).await; + // Cache addr for QUIC connection when we join, track npub for lobby UI + log_info!("[WEBXDC] Caching peer addr for topic {} (no active channel yet)", topic_id); + state.cache_peer_addr(topic, node_addr).await; + state.add_session_peer(topic, sender_npub.to_string()).await; + + // Emit event to frontend so it can update the UI (show "Click to Join" and player avatars) + let peer_npubs = state.get_session_peers(&topic).await; + let peer_count = peer_npubs.len(); if let Some(main_window) = handle.get_webview_window("main") { use tauri::Emitter; let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ "topic": topic_id, - "peer_count": pending_count, + "peer_count": peer_count, + "peers": peer_npubs, "is_active": false, - "has_pending_peers": true, + "has_pending_peers": peer_count > 0, })); - log_info!("[WEBXDC] Emitted miniapp_realtime_status event: topic={}, pending_count={}", topic_id, pending_count); + log_info!("[WEBXDC] Emitted miniapp_realtime_status event: topic={}, peer_count={}", topic_id, peer_count); } return true; } } - + + false +} + +/// Handle a WebXDC peer-left signal — a peer closed their Mini App. +/// Removes pending peers for the topic and emits a status update. +pub(crate) async fn handle_webxdc_peer_left( + event_id: &str, + topic_id: &str, + sender_npub: &str, + created_at: u64, + conversation_id: &str, +) -> bool { + use crate::miniapps::realtime::decode_topic_id; + + log_info!("[WEBXDC] Received peer-left from {} for topic {}", sender_npub, topic_id); + + // Persist to SQLite — invalidates any older peer-advertisement from this npub + if !db::event_exists(event_id).unwrap_or(true) { + if let Ok(chat_id) = db::get_or_create_chat_id(conversation_id) { + let tags = vec![ + vec!["webxdc-topic".to_string(), topic_id.to_string()], + vec!["d".to_string(), "vector-webxdc-peer".to_string()], + ]; + let event = crate::stored_event::StoredEvent { + id: event_id.to_string(), + kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC, + chat_id, + user_id: None, + content: "peer-left".to_string(), + tags, + reference_id: Some(topic_id.to_string()), + created_at, + received_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + mine: false, + pending: false, + failed: false, + wrapper_event_id: None, + npub: Some(sender_npub.to_string()), + preview_metadata: None, + }; + if let Err(e) = db::save_event(&event).await { + log_warn!("[WEBXDC] Failed to persist peer-left: {}", e); + } + } + } + + let topic = match decode_topic_id(topic_id) { + Ok(t) => t, + Err(e) => { + log_warn!("Failed to decode topic ID in peer-left: {}", e); + return false; + } + }; + + if let Some(handle) = TAURI_APP.get() { + let state = handle.state::(); + + // Remove from session peers (lobby state) + state.remove_session_peer(&topic, sender_npub).await; + + // Check if we're actively playing + let we_are_playing = { + let channels = state.realtime_channels.read().await; + channels.values().any(|ch| ch.topic == topic && ch.active) + }; + + // Emit updated status — peer_count = session_peers.len() (single source of truth) + let peer_npubs = state.get_session_peers(&topic).await; + let peer_count = peer_npubs.len(); + if let Some(main_window) = handle.get_webview_window("main") { + use tauri::Emitter; + let _ = main_window.emit("miniapp_realtime_status", serde_json::json!({ + "topic": topic_id, + "peer_count": peer_count, + "peers": peer_npubs, + "is_active": we_are_playing, + "has_pending_peers": peer_count > 0, + })); + log_info!("[WEBXDC] Peer-left status update: topic={}, remaining={}", topic_id, peer_count); + } + + return true; + } + false } \ No newline at end of file diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 3f6b3f35..824bbc81 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod notification_service; pub(crate) use event_handler::handle_event; pub(crate) use event_handler::handle_webxdc_peer_advertisement; +pub(crate) use event_handler::handle_webxdc_peer_left; pub(crate) use event_handler::{prepare_event, commit_prepared_event}; pub(crate) use subscription_handler::start_subscriptions; pub(crate) use notification_service::{NotificationData, show_notification_generic, resolve_mention_display_names, strip_content_for_preview}; diff --git a/src-tauri/src/services/subscription_handler.rs b/src-tauri/src/services/subscription_handler.rs index f1ca4c5b..cb4c22e4 100644 --- a/src-tauri/src/services/subscription_handler.rs +++ b/src-tauri/src/services/subscription_handler.rs @@ -452,8 +452,12 @@ pub(crate) async fn handle_mls_group_message(event: Event, my_public_key: Public } None } - RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr } => { - super::handle_webxdc_peer_advertisement(&topic_id, &node_addr).await; + RumorProcessingResult::WebxdcPeerAdvertisement { event_id, topic_id, node_addr, sender_npub, created_at } => { + super::handle_webxdc_peer_advertisement(&event_id, &topic_id, &node_addr, &sender_npub, created_at, &group_id_for_persist).await; + None + } + RumorProcessingResult::WebxdcPeerLeft { event_id, topic_id, sender_npub, created_at } => { + super::handle_webxdc_peer_left(&event_id, &topic_id, &sender_npub, created_at, &group_id_for_persist).await; None } RumorProcessingResult::UnknownEvent(mut event) => { diff --git a/src/main.js b/src/main.js index 27961a4a..c2a96c1c 100644 --- a/src/main.js +++ b/src/main.js @@ -5050,17 +5050,15 @@ async function setupRustListeners() { // Listen for Mini App realtime status updates (peer count changes) _on('miniapp_realtime_status', (evt) => { - const { topic, peer_count, is_active, has_pending_peers } = evt.payload; - console.log('[MINIAPP] Realtime status update:', topic, 'peers:', peer_count, 'active:', is_active, 'pending:', has_pending_peers); - + const { topic, peer_count, is_active, has_pending_peers, peers } = evt.payload; + console.log('[MINIAPP] Realtime status update:', topic, 'peers:', peer_count, 'active:', is_active, 'npubs:', peers); + // Find all Mini App attachments with this topic and update their status const attachments = document.querySelectorAll(`.miniapp-attachment[data-webxdc-topic="${topic}"]`); - console.log('[MINIAPP] Found', attachments.length, 'attachments for topic', topic); - + attachments.forEach(attachment => { - // Use the stored update function if available if (attachment._updateMiniAppStatus) { - attachment._updateMiniAppStatus(is_active, peer_count); + attachment._updateMiniAppStatus(is_active, peer_count, peers); } }); }); @@ -7046,7 +7044,7 @@ function createFileBox(cAttachment, state = 'downloaded') { // Create the main container const btnDiv = document.createElement('div'); - btnDiv.className = 'btn custom-audio-player'; + btnDiv.className = isMiniApp ? 'custom-audio-player' : 'btn custom-audio-player'; btnDiv.style.display = 'flex'; btnDiv.style.alignItems = 'center'; btnDiv.style.padding = '10px'; @@ -7061,7 +7059,7 @@ function createFileBox(cAttachment, state = 'downloaded') { iconElement.style.height = '40px'; iconElement.style.borderRadius = '8px'; iconElement.style.objectFit = 'cover'; - iconElement.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; + iconElement.style.backgroundColor = 'transparent'; iconElement.src = 'data:image/svg+xml,'; } else { iconElement = document.createElement('span'); @@ -7107,10 +7105,11 @@ function createFileBox(cAttachment, state = 'downloaded') { const peerBadge = document.createElement('span'); peerBadge.style.padding = '2px 8px'; peerBadge.style.borderRadius = '10px'; - peerBadge.style.backgroundColor = 'rgba(46, 213, 115, 0.3)'; - peerBadge.style.color = '#2ed573'; + peerBadge.style.backgroundColor = 'rgba(46, 213, 115, 0.15)'; + peerBadge.style.color = 'rgb(46, 213, 115)'; peerBadge.style.fontSize = '0.85em'; peerBadge.style.fontWeight = '500'; + peerBadge.style.border = '0.5px solid rgba(46, 213, 115, 0.3)'; peerBadge.style.display = 'none'; smallElement.appendChild(peerBadge); @@ -7120,19 +7119,56 @@ function createFileBox(cAttachment, state = 'downloaded') { fileDiv.setAttribute('data-webxdc-topic', topicId); } - // Helper function to update the peer badge - const updatePeerBadge = (peerCount, isPlaying) => { - const totalPlayers = isPlaying ? peerCount + 1 : peerCount; + // Helper function to update the peer badge with avatar stack + const updatePeerBadge = (peerCount, isPlaying, peerNpubs) => { + // session_peers is the single source of truth — use its length as the count + const totalPlayers = (peerNpubs && peerNpubs.length > 0) ? peerNpubs.length : (isPlaying ? peerCount + 1 : peerCount); if (totalPlayers > 0) { - const groupIcon = document.createElement('img'); - groupIcon.src = 'icons/group-placeholder.svg'; - groupIcon.style.width = '14px'; - groupIcon.style.height = '14px'; - groupIcon.style.verticalAlign = 'middle'; - groupIcon.style.marginRight = '4px'; - peerBadge.innerHTML = ''; - peerBadge.appendChild(groupIcon); + + // Avatar stack — show up to 5 tiny overlapping profile pictures + const npubs = (peerNpubs || []).sort(); // deterministic order + const shown = npubs.slice(0, 5); + if (shown.length > 0) { + const stack = document.createElement('span'); + stack.style.display = 'inline-flex'; + stack.style.alignItems = 'center'; + stack.style.marginRight = '4px'; + shown.forEach((npub, i) => { + const wrapper = document.createElement('span'); + wrapper.style.position = 'relative'; + wrapper.style.zIndex = String(shown.length - i); + if (i > 0) wrapper.style.marginLeft = '-5px'; + const img = document.createElement('img'); + const profile = getProfile(npub); + const src = getProfileAvatarSrc(profile); + const displayName = profile?.nickname || profile?.name; + if (displayName) { + wrapper.addEventListener('mouseenter', () => showGlobalTooltip(displayName, wrapper)); + wrapper.addEventListener('mouseleave', hideGlobalTooltip); + } + img.onerror = function() { this.src = 'icons/contact-placeholder.svg'; }; + img.src = src || 'icons/contact-placeholder.svg'; + img.style.width = '14px'; + img.style.height = '14px'; + img.style.borderRadius = '50%'; + img.style.border = '1px solid #1a1a2e'; + img.style.objectFit = 'cover'; + img.style.display = 'block'; + wrapper.appendChild(img); + stack.appendChild(wrapper); + }); + peerBadge.appendChild(stack); + } else { + const groupIcon = document.createElement('img'); + groupIcon.src = 'icons/group-placeholder.svg'; + groupIcon.style.width = '14px'; + groupIcon.style.height = '14px'; + groupIcon.style.verticalAlign = 'middle'; + groupIcon.style.marginRight = '4px'; + peerBadge.appendChild(groupIcon); + } + peerBadge.appendChild(document.createTextNode(`${totalPlayers} online`)); peerBadge.style.display = 'inline-flex'; peerBadge.style.alignItems = 'center'; @@ -7141,28 +7177,29 @@ function createFileBox(cAttachment, state = 'downloaded') { } }; + // Track last known peer npubs (preserved across status updates that omit it) + let lastPeerNpubs = []; + // Helper function to update the UI based on status - updateMiniAppStatus = (isPlaying, peerCount) => { + updateMiniAppStatus = (isPlaying, peerCount, peerNpubs) => { + if (peerNpubs) lastPeerNpubs = peerNpubs; if (isPlaying) { playSpan.innerText = 'Playing'; playSpan.style.color = '#2ed573'; fileDiv.style.cursor = 'default'; - fileDiv.style.opacity = '0.9'; fileDiv.setAttribute('data-playing', 'true'); } else if (peerCount > 0) { playSpan.innerText = 'Click to Join'; playSpan.style.color = 'rgba(255, 255, 255, 0.7)'; fileDiv.style.cursor = 'pointer'; - fileDiv.style.opacity = '1'; fileDiv.removeAttribute('data-playing'); } else { playSpan.innerText = 'Click to Play'; playSpan.style.color = 'rgba(255, 255, 255, 0.7)'; fileDiv.style.cursor = 'pointer'; - fileDiv.style.opacity = '1'; fileDiv.removeAttribute('data-playing'); } - updatePeerBadge(peerCount, isPlaying); + updatePeerBadge(peerCount, isPlaying, lastPeerNpubs); }; // Load Mini App info asynchronously to get name and icon @@ -7188,7 +7225,7 @@ function createFileBox(cAttachment, state = 'downloaded') { const peerCount = (status?.peer_count || 0) > 0 ? status.peer_count : (status?.pending_peer_count || 0); - updateMiniAppStatus(status?.active || false, peerCount); + updateMiniAppStatus(status?.active || false, peerCount, status?.peers); }) .catch(err => { console.debug('Could not get realtime status:', err); @@ -7907,13 +7944,7 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { if (!path) return; if (isMiniApp) { - // Check if we're already playing - if (fileDiv.getAttribute('data-playing') === 'true') { - console.log('[MiniApp] Already playing, ignoring click'); - return; - } - - // Open Mini App in a new window + // Open Mini App in a new window (or focus existing) try { const attachment = msg.attachments.find(a => a.path === path); const topicId = attachment?.webxdc_topic || null; @@ -7927,10 +7958,10 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { if (topicId) { invoke('miniapp_get_realtime_status', { topicId }) .then(status => { - fileDiv._updateMiniAppStatus(true, status?.peer_count || 0); + fileDiv._updateMiniAppStatus(true, status?.peer_count || 0, status?.peers); }) .catch(() => { - fileDiv._updateMiniAppStatus(true, 0); + fileDiv._updateMiniAppStatus(true, 0, []); }); } else { fileDiv._updateMiniAppStatus(true, 0); diff --git a/src/styles.css b/src/styles.css index 3511e3e2..65bce49e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3253,6 +3253,7 @@ audio { opacity: 0.75; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; @@ -5024,6 +5025,7 @@ select:disabled:hover { } + .audio-play-btn { width: 40px; height: 40px; @@ -7398,7 +7400,7 @@ hr { width: 100px; height: 100px; border-radius: 20px; - background: rgba(255, 255, 255, 0.1); + background: transparent; display: flex; align-items: center; justify-content: center; From 2105b46038640917930776ed3dc985a964f70dca Mon Sep 17 00:00:00 2001 From: JSKitty Date: Thu, 19 Mar 2026 10:26:57 +0000 Subject: [PATCH 003/417] fix: Mini App download spinner overlapping text in chat bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The download progress spinner is absolute-positioned, taking no flow space. The text container needs explicit margin to clear it. Fixed in both paths: - Initial render (downloading state on page load) - Click-to-download (icon replaced with spinner mid-session) Also aligned spinner left position (10px → 15px) to match the Mini App icon position, eliminating visual jitter when transitioning between states. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index c2a96c1c..dba386a1 100644 --- a/src/main.js +++ b/src/main.js @@ -6992,9 +6992,9 @@ function createFileBoxSpinner(target, opts = {}) { spinner.className = 'miniapp-downloading-spinner'; if (opts.id) spinner.id = opts.id; if (opts.attachmentId) spinner.setAttribute('data-attachment-id', opts.attachmentId); - // Match the icon's absolute positioning to prevent layout shift + // Match the Mini App icon position (marginLeft:5px + padding:10px = 15px from edge) spinner.style.position = 'absolute'; - spinner.style.left = '10px'; + spinner.style.left = '15px'; spinner.style.top = '0'; spinner.style.bottom = '0'; spinner.style.margin = 'auto'; @@ -7013,6 +7013,9 @@ function createFileBoxSpinner(target, opts = {}) { target.style.opacity = '0'; target.style.scale = '0.5'; setTimeout(() => { + // Spinner is absolute-positioned — push sibling text past it + const textSibling = target.parentElement?.querySelector('span'); + if (textSibling) textSibling.style.marginLeft = '60px'; target.replaceWith(spinner); requestAnimationFrame(() => { spinner.style.opacity = '1'; spinner.style.scale = '1'; }); setTimeout(settleTransition, 300); @@ -7265,6 +7268,8 @@ function createFileBox(cAttachment, state = 'downloaded') { // Replace icon with conical progress spinner iconElement = createFileBoxSpinner(null, { attachmentId: cAttachment.id }); + // Spinner is absolute-positioned (left:10px, width:40px) — push text past it + textContainerSpan.style.marginLeft = '60px'; } else { // 'download' — waiting for user to click let strSize = ''; From 555c5590d3d776d7390efeb9a5bcb9b28112ff16 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Thu, 19 Mar 2026 14:11:26 +0000 Subject: [PATCH 004/417] feat: add Share button to own profile, move banner edit to bottom-right - Add Share button (top-right of banner) on own profile using existing .profile-option design, scaled to 75% with transform - Copies profile link to clipboard with toast + checkmark feedback - Move banner edit icon from top-right to bottom-right to make room - Share button hidden on other profiles (they have it in the options row) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.html | 4 ++++ src/main.js | 21 ++++++++++++++++++++- src/styles.css | 13 ++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/index.html b/src/index.html index 18070ed1..d9c28cde 100644 --- a/src/index.html +++ b/src/index.html @@ -557,6 +557,10 @@

+
+ + +
diff --git a/src/main.js b/src/main.js index dba386a1..5e9a74d3 100644 --- a/src/main.js +++ b/src/main.js @@ -6056,6 +6056,24 @@ function renderProfileTab(cProfile) { document.querySelector('.profile-banner-edit').style.display = 'flex'; document.querySelector('.profile-banner-edit').onclick = askForBanner; + + // Show Share button on own profile (top-right of banner) + const ownShareBtn = document.getElementById('profile-share-btn'); + ownShareBtn.style.display = 'block'; + ownShareBtn.onclick = () => { + const npub = document.getElementById('profile-npub')?.dataset.fullNpub; + if (npub) { + const profileUrl = `https://vectorapp.io/profile/${npub}`; + navigator.clipboard.writeText(profileUrl).then(() => { + const icon = ownShareBtn.querySelector('span'); + showToast('Profile Link Copied'); + icon.classList.replace('icon-share', 'icon-check'); + setTimeout(() => icon.classList.replace('icon-check', 'icon-share'), 2000); + }).catch(() => { + showToast('Failed to copy profile link'); + }); + } + }; // Hide the 'Back' button and deregister its clickable function domProfileBackBtn.style.display = 'none'; @@ -6125,9 +6143,10 @@ function renderProfileTab(cProfile) { } }; - // Hide edit buttons + // Hide edit buttons and own-profile share document.querySelector('.profile-avatar-edit').style.display = 'none'; document.querySelector('.profile-banner-edit').style.display = 'none'; + document.getElementById('profile-share-btn').style.display = 'none'; // Remove click handlers from avatar and banner domProfileAvatar.onclick = null; diff --git a/src/styles.css b/src/styles.css index 65bce49e..9d792d26 100644 --- a/src/styles.css +++ b/src/styles.css @@ -838,7 +838,7 @@ html, body { .profile-banner-edit { position: absolute; - top: 10px; + bottom: 10px; right: 10px; width: 30px; height: 30px; @@ -850,6 +850,17 @@ html, body { cursor: pointer; } +.profile-share-btn { + position: absolute; + top: 10px; + right: 10px; + display: none; + margin: 0; + transform: scale(0.75); + transform-origin: top right; + opacity: 1 !important; +} + /* Style individual options within the danger zone */ .danger-option { display: flex; From 22738abd56141958f2d334ade02f5972b8192bc1 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Fri, 20 Mar 2026 00:19:24 +0000 Subject: [PATCH 005/417] feat: add user blocking with full DM/group filtering, UI controls, and privacy protections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add BLOCKED bit (0b010) to ProfileFlags — zero extra memory (packed u8) - Migration 20: add is_blocked column to profiles table - block_user, unblock_user, get_blocked_users Tauri commands with ACL - Self-block prevention via MY_PUBLIC_KEY check - DM events from blocked users dropped after decrypt (wrapper kept for negentropy) - Combined block+mute check in single STATE lock (saves redundant lock acquisition) - Group chat notifications suppressed for blocked senders (@mentions, @everyone, regular) - OS badge counter skips blocked users in both DM and group chats Frontend: - Profile "More" dropdown menu (three-dot icon) with Nickname and Block/Unblock - Block confirmation dialog, toast feedback, chatlist re-render on block/unblock - Blocked DM chats hidden from chat list, input disabled with "Unblock to send messages" - System notice "Blocked — You won't receive new messages from them" in blocked DM chats - Group messages from blocked users show as "Blocked message" with cancel icon placeholder - Click-to-reveal blocked messages at 40% opacity via revealedBlockedMessages Set - Revealed messages skip auto-download, web previews, and inline image loading (IP leak prevention) - Blocked users filtered from group chat previews, unread counts, and typing indicators - Settings > Privacy: collapsible blocked users list with avatar, name, npub hint, and unblock - Image download button now swaps to centered progress spinner on click (general fix) - File attachment text margin standardized to 60px across all states (general fix) - openChat clears DOM for fresh re-render on state changes Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/capabilities/default.json | 3 + .../permissions/autogenerated/block_user.toml | 11 + .../autogenerated/get_blocked_users.toml | 11 + .../autogenerated/unblock_user.toml | 11 + src-tauri/src/account_manager.rs | 12 + src-tauri/src/db/profiles.rs | 15 +- src-tauri/src/lib.rs | 3 + src-tauri/src/profile.rs | 91 +++++++- src-tauri/src/services/event_handler.rs | 127 +++++------ .../src/services/subscription_handler.rs | 24 +- src-tauri/src/state/chat_state.rs | 16 ++ src-tauri/src/util.rs | 2 +- src/icons/dots-horizontal.svg | 5 + src/index.html | 29 ++- src/js/misc.js | 2 +- src/js/settings.js | 77 +++++++ src/main.js | 205 ++++++++++++++++-- src/styles.css | 64 ++++++ 18 files changed, 601 insertions(+), 107 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/block_user.toml create mode 100644 src-tauri/permissions/autogenerated/get_blocked_users.toml create mode 100644 src-tauri/permissions/autogenerated/unblock_user.toml create mode 100644 src/icons/dots-horizontal.svg diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d080259b..1386e7d6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -27,6 +27,9 @@ "allow-update-status", "allow-upload-avatar", "allow-set-nickname", + "allow-block-user", + "allow-unblock-user", + "allow-get-blocked-users", "allow-mark-as-read", "allow-toggle-chat-mute", "allow-message", diff --git a/src-tauri/permissions/autogenerated/block_user.toml b/src-tauri/permissions/autogenerated/block_user.toml new file mode 100644 index 00000000..11fe15fa --- /dev/null +++ b/src-tauri/permissions/autogenerated/block_user.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-block-user" +description = "Enables the block_user command without any pre-configured scope." +commands.allow = ["block_user"] + +[[permission]] +identifier = "deny-block-user" +description = "Denies the block_user command without any pre-configured scope." +commands.deny = ["block_user"] diff --git a/src-tauri/permissions/autogenerated/get_blocked_users.toml b/src-tauri/permissions/autogenerated/get_blocked_users.toml new file mode 100644 index 00000000..63ff51dd --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_blocked_users.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-blocked-users" +description = "Enables the get_blocked_users command without any pre-configured scope." +commands.allow = ["get_blocked_users"] + +[[permission]] +identifier = "deny-get-blocked-users" +description = "Denies the get_blocked_users command without any pre-configured scope." +commands.deny = ["get_blocked_users"] diff --git a/src-tauri/permissions/autogenerated/unblock_user.toml b/src-tauri/permissions/autogenerated/unblock_user.toml new file mode 100644 index 00000000..3b109817 --- /dev/null +++ b/src-tauri/permissions/autogenerated/unblock_user.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-unblock-user" +description = "Enables the unblock_user command without any pre-configured scope." +commands.allow = ["unblock_user"] + +[[permission]] +identifier = "deny-unblock-user" +description = "Denies the unblock_user command without any pre-configured scope." +commands.deny = ["unblock_user"] diff --git a/src-tauri/src/account_manager.rs b/src-tauri/src/account_manager.rs index 4b7c265d..bf79ef26 100644 --- a/src-tauri/src/account_manager.rs +++ b/src-tauri/src/account_manager.rs @@ -1390,6 +1390,18 @@ fn run_migrations(conn: &mut rusqlite::Connection) -> Result<(), String> { Ok(()) })?; + // ========================================================================= + // Migration 20: Add is_blocked column to profiles table + // ========================================================================= + // Supports user blocking: blocked profiles have DM events dropped after + // decrypt (wrapper kept for negentropy), group messages filtered in UI. + run_atomic_migration(conn, 20, "Add is_blocked column to profiles", |tx| { + tx.execute_batch( + "ALTER TABLE profiles ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0;" + ).map_err(|e| format!("Failed to add is_blocked column: {}", e))?; + Ok(()) + })?; + Ok(()) } diff --git a/src-tauri/src/db/profiles.rs b/src-tauri/src/db/profiles.rs index 36857269..516442ad 100644 --- a/src-tauri/src/db/profiles.rs +++ b/src-tauri/src/db/profiles.rs @@ -33,6 +33,7 @@ pub struct SlimProfile { pub last_updated: u64, pub mine: bool, pub bot: bool, + pub is_blocked: bool, pub avatar_cached: String, pub banner_cached: String, } @@ -55,6 +56,7 @@ impl Default for SlimProfile { last_updated: 0, mine: false, bot: false, + is_blocked: false, avatar_cached: String::new(), banner_cached: String::new(), } @@ -84,6 +86,7 @@ impl SlimProfile { last_updated: secs_from_compact(profile.last_updated), mine: profile.flags.is_mine(), bot: profile.flags.is_bot(), + is_blocked: profile.flags.is_blocked(), avatar_cached: profile.avatar_cached.to_string(), banner_cached: profile.banner_cached.to_string(), } @@ -94,6 +97,7 @@ impl SlimProfile { let mut flags = ProfileFlags::default(); flags.set_mine(self.mine); flags.set_bot(self.bot); + flags.set_blocked(self.is_blocked); crate::Profile { id: crate::message::compact::NO_NPUB, @@ -122,7 +126,7 @@ impl SlimProfile { pub async fn get_all_profiles() -> Result, String> { let conn = crate::account_manager::get_db_connection_guard_static()?; - let mut stmt = conn.prepare("SELECT npub, name, display_name, nickname, lud06, lud16, banner, avatar, about, website, nip05, status_content, status_url, bot, avatar_cached, banner_cached FROM profiles") + let mut stmt = conn.prepare("SELECT npub, name, display_name, nickname, lud06, lud16, banner, avatar, about, website, nip05, status_content, status_url, bot, avatar_cached, banner_cached, is_blocked FROM profiles") .map_err(|e| format!("Failed to prepare statement: {}", e))?; let profiles = stmt.query_map([], |row| { @@ -154,6 +158,7 @@ pub async fn get_all_profiles() -> Result, String> { let p: String = row.get(15)?; if !p.is_empty() && !std::path::Path::new(&p).exists() { String::new() } else { p } }, + is_blocked: row.get::<_, i32>(16).unwrap_or(0) != 0, }) }) .map_err(|e| format!("Failed to query profiles: {}", e))? @@ -171,8 +176,8 @@ pub async fn set_profile(profile: SlimProfile) -> Result<(), String> { let conn = crate::account_manager::get_write_connection_guard_static()?; conn.execute( - "INSERT INTO profiles (npub, name, display_name, nickname, lud06, lud16, banner, avatar, about, website, nip05, status_content, status_url, bot, avatar_cached, banner_cached) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + "INSERT INTO profiles (npub, name, display_name, nickname, lud06, lud16, banner, avatar, about, website, nip05, status_content, status_url, bot, avatar_cached, banner_cached, is_blocked) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) ON CONFLICT(npub) DO UPDATE SET name = excluded.name, display_name = excluded.display_name, @@ -188,7 +193,8 @@ pub async fn set_profile(profile: SlimProfile) -> Result<(), String> { status_url = excluded.status_url, bot = excluded.bot, avatar_cached = excluded.avatar_cached, - banner_cached = excluded.banner_cached", + banner_cached = excluded.banner_cached, + is_blocked = excluded.is_blocked", rusqlite::params![ profile.id, // This is the npub profile.name, @@ -206,6 +212,7 @@ pub async fn set_profile(profile: SlimProfile) -> Result<(), String> { profile.bot as i32, profile.avatar_cached, profile.banner_cached, + profile.is_blocked as i32, ], ).map_err(|e| format!("Failed to insert profile: {}", e))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 576e06fe..74aaadc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -314,6 +314,9 @@ pub fn run() { chat::mark_as_read, chat::toggle_chat_mute, profile::set_nickname, + profile::block_user, + profile::unblock_user, + profile::get_blocked_users, message::message, message::delete_failed_message, message::cancel_upload, diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 70f248b0..17fe5076 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -17,21 +17,24 @@ use crate::message::AttachmentFile; use crate::android::filesystem; // ============================================================================ -// ProfileFlags — 2 bools packed into 1 byte +// ProfileFlags — 3 bools packed into 1 byte // ============================================================================ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ProfileFlags(u8); impl ProfileFlags { - const MINE: u8 = 0b001; - const BOT: u8 = 0b100; + const MINE: u8 = 0b001; + const BLOCKED: u8 = 0b010; + const BOT: u8 = 0b100; - #[inline] pub fn is_mine(self) -> bool { self.0 & Self::MINE != 0 } - #[inline] pub fn is_bot(self) -> bool { self.0 & Self::BOT != 0 } + #[inline] pub fn is_mine(self) -> bool { self.0 & Self::MINE != 0 } + #[inline] pub fn is_blocked(self) -> bool { self.0 & Self::BLOCKED != 0 } + #[inline] pub fn is_bot(self) -> bool { self.0 & Self::BOT != 0 } - #[inline] pub fn set_mine(&mut self, v: bool) { if v { self.0 |= Self::MINE } else { self.0 &= !Self::MINE } } - #[inline] pub fn set_bot(&mut self, v: bool) { if v { self.0 |= Self::BOT } else { self.0 &= !Self::BOT } } + #[inline] pub fn set_mine(&mut self, v: bool) { if v { self.0 |= Self::MINE } else { self.0 &= !Self::MINE } } + #[inline] pub fn set_blocked(&mut self, v: bool) { if v { self.0 |= Self::BLOCKED } else { self.0 &= !Self::BLOCKED } } + #[inline] pub fn set_bot(&mut self, v: bool) { if v { self.0 |= Self::BOT } else { self.0 &= !Self::BOT } } } // ============================================================================ @@ -64,7 +67,7 @@ pub struct Profile { pub status_url: Box, /// Compact timestamp: seconds since 2020 epoch (valid until 2156) pub last_updated: u32, - /// Packed boolean flags: mine | bot + /// Packed boolean flags: mine | blocked | bot pub flags: ProfileFlags, /// Local cached path for avatar image (for offline support) pub avatar_cached: Box, @@ -783,6 +786,78 @@ pub async fn upload_avatar(filepath: String, upload_type: Option) -> Res } +/// Blocks a user by npub. DM events from blocked users are dropped after decryption. +/// Group messages are stored but filtered in the UI. +#[tauri::command] +pub async fn block_user(npub: String) -> bool { + // Prevent blocking yourself (would break Notes/Bookmarks and self-DM processing) + if let Some(&my_pk) = crate::MY_PUBLIC_KEY.get() { + if my_pk.to_bech32().ok().as_deref() == Some(npub.as_str()) { + return false; + } + } + + let handle = TAURI_APP.get().unwrap(); + let mut state = STATE.lock().await; + + // Create profile if it doesn't exist (can block someone with no prior contact) + if state.interner.lookup(&npub).is_none() { + let new_profile = Profile::new(); + state.insert_or_replace_profile(&npub, new_profile); + } + + if let Some(id) = state.interner.lookup(&npub) { + { + let profile = match state.get_profile_mut_by_id(id) { + Some(p) => p, + None => return false, + }; + profile.flags.set_blocked(true); + } + let slim = state.serialize_profile(id).unwrap(); + handle.emit("profile_update", &slim).ok(); + drop(state); + db::set_profile(slim).await.ok(); + true + } else { + false + } +} + +/// Unblocks a user by npub. +#[tauri::command] +pub async fn unblock_user(npub: String) -> bool { + let handle = TAURI_APP.get().unwrap(); + let mut state = STATE.lock().await; + + if let Some(id) = state.interner.lookup(&npub) { + { + let profile = match state.get_profile_mut_by_id(id) { + Some(p) => p, + None => return false, + }; + profile.flags.set_blocked(false); + } + let slim = state.serialize_profile(id).unwrap(); + handle.emit("profile_update", &slim).ok(); + drop(state); + db::set_profile(slim).await.ok(); + true + } else { + false + } +} + +/// Returns all blocked profiles for the Settings blocked users list. +#[tauri::command] +pub async fn get_blocked_users() -> Vec { + let state = STATE.lock().await; + state.profiles.iter() + .filter(|p| p.flags.is_blocked()) + .filter_map(|p| state.serialize_profile(p.id)) + .collect() +} + /// Sets a nickname for a profile #[tauri::command] pub async fn set_nickname(npub: String, nickname: String) -> bool { diff --git a/src-tauri/src/services/event_handler.rs b/src-tauri/src/services/event_handler.rs index f58536b3..dff953d7 100644 --- a/src-tauri/src/services/event_handler.rs +++ b/src-tauri/src/services/event_handler.rs @@ -101,6 +101,18 @@ pub(crate) async fn handle_event_with_context( // Persist wrapper for negentropy reconciliation (so next boot knows we've seen this event) let _ = db::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at); + // Single lock: check blocked (drop event) + muted (suppress notification later) + let is_contact_muted = if !is_mine { + let state = STATE.lock().await; + if state.get_profile(&contact).map_or(false, |p| p.flags.is_blocked()) { + // Wrapper already persisted above for negentropy. Content never stored. + return false; + } + state.get_chat(&contact).map_or(false, |c| c.muted) + } else { + false + }; + // Skip NIP-17 group messages (multiple p-tags) — Vector uses MLS for group chats. // Without this, multi-recipient messages appear incorrectly in random DM chats. // The wrapper is already persisted above for negentropy reconciliation. @@ -255,12 +267,12 @@ pub(crate) async fn handle_event_with_context( RumorProcessingResult::TextMessage(mut msg) => { // Set the wrapper event ID for database storage msg.wrapper_event_id = Some(wrapper_event_id.clone()); - handle_text_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes).await + handle_text_message(msg, &contact, is_mine, is_new, is_contact_muted, &wrapper_event_id, wrapper_event_id_bytes).await } RumorProcessingResult::FileAttachment(mut msg) => { // Set the wrapper event ID for database storage msg.wrapper_event_id = Some(wrapper_event_id.clone()); - handle_file_attachment(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes).await + handle_file_attachment(msg, &contact, is_mine, is_new, is_contact_muted, &wrapper_event_id, wrapper_event_id_bytes).await } RumorProcessingResult::Reaction(reaction) => { let result = handle_reaction(reaction.clone(), &contact).await; @@ -376,7 +388,7 @@ pub(crate) async fn handle_event_with_context( } /// Handle a processed text message -async fn handle_text_message(mut msg: Message, contact: &str, is_mine: bool, is_new: bool, wrapper_event_id: &str, wrapper_event_id_bytes: [u8; 32]) -> bool { +async fn handle_text_message(mut msg: Message, contact: &str, is_mine: bool, is_new: bool, is_contact_muted: bool, wrapper_event_id: &str, wrapper_event_id_bytes: [u8; 32]) -> bool { // Check if message already exists in database (important for sync with partial message loading) if let Ok(exists) = db::message_exists_in_db(&msg.id).await { if exists { @@ -416,41 +428,37 @@ async fn handle_text_message(mut msg: Message, contact: &str, is_mine: bool, is_ } // Send OS notification for incoming messages (only after confirming message is new) + // Mute status pre-checked during block check — skip lock entirely when muted if !is_mine && is_new { - let display_info = { + let display_info = if is_contact_muted { + None + } else { let state = STATE.lock().await; - // DM mute blocks everything including mentions - let chat_muted = state.get_chat(contact) - .map_or(false, |c| c.muted); - if chat_muted { - None - } else { - match state.get_profile(contact) { - Some(profile) => { - let display_name = if !profile.nickname.is_empty() { - profile.nickname.to_string() - } else if !profile.name.is_empty() { - profile.name.to_string() - } else { - String::from("New Message") - }; - let avatar = if !profile.avatar_cached.is_empty() { - Some(profile.avatar_cached.to_string()) - } else { - None - }; - let resolved_content = crate::services::strip_content_for_preview( - &crate::services::resolve_mention_display_names(&msg.content, &state) - ); - Some((display_name, resolved_content, avatar)) - } - None => { - let resolved_content = crate::services::strip_content_for_preview( - &crate::services::resolve_mention_display_names(&msg.content, &state) - ); - Some((String::from("New Message"), resolved_content, None)) - }, + match state.get_profile(contact) { + Some(profile) => { + let display_name = if !profile.nickname.is_empty() { + profile.nickname.to_string() + } else if !profile.name.is_empty() { + profile.name.to_string() + } else { + String::from("New Message") + }; + let avatar = if !profile.avatar_cached.is_empty() { + Some(profile.avatar_cached.to_string()) + } else { + None + }; + let resolved_content = crate::services::strip_content_for_preview( + &crate::services::resolve_mention_display_names(&msg.content, &state) + ); + Some((display_name, resolved_content, avatar)) } + None => { + let resolved_content = crate::services::strip_content_for_preview( + &crate::services::resolve_mention_display_names(&msg.content, &state) + ); + Some((String::from("New Message"), resolved_content, None)) + }, } }; if let Some((display_name, content, avatar)) = display_info { @@ -471,7 +479,7 @@ async fn handle_text_message(mut msg: Message, contact: &str, is_mine: bool, is_ } /// Handle a processed file attachment -async fn handle_file_attachment(mut msg: Message, contact: &str, is_mine: bool, is_new: bool, wrapper_event_id: &str, wrapper_event_id_bytes: [u8; 32]) -> bool { +async fn handle_file_attachment(mut msg: Message, contact: &str, is_mine: bool, is_new: bool, is_contact_muted: bool, wrapper_event_id: &str, wrapper_event_id_bytes: [u8; 32]) -> bool { // Check if message already exists in database (important for sync with partial message loading) if let Ok(exists) = db::message_exists_in_db(&msg.id).await { if exists { @@ -532,32 +540,27 @@ async fn handle_file_attachment(mut msg: Message, contact: &str, is_mine: bool, // Send OS notification for incoming files (only after confirming message is new) if !is_mine && is_new { - let display_info = { + let display_info = if is_contact_muted { + None + } else { let state = STATE.lock().await; - // DM mute blocks everything including mentions - let chat_muted = state.get_chat(contact) - .map_or(false, |c| c.muted); - if chat_muted { - None - } else { - match state.get_profile(contact) { - Some(profile) => { - let display_name = if !profile.nickname.is_empty() { - profile.nickname.to_string() - } else if !profile.name.is_empty() { - profile.name.to_string() - } else { - String::from("New Message") - }; - let avatar = if !profile.avatar_cached.is_empty() { - Some(profile.avatar_cached.to_string()) - } else { - None - }; - Some((display_name, extension.clone(), avatar)) - } - None => Some((String::from("New Message"), extension.clone(), None)), + match state.get_profile(contact) { + Some(profile) => { + let display_name = if !profile.nickname.is_empty() { + profile.nickname.to_string() + } else if !profile.name.is_empty() { + profile.name.to_string() + } else { + String::from("New Message") + }; + let avatar = if !profile.avatar_cached.is_empty() { + Some(profile.avatar_cached.to_string()) + } else { + None + }; + Some((display_name, extension.clone(), avatar)) } + None => Some((String::from("New Message"), extension.clone(), None)), } }; if let Some((display_name, file_extension, avatar)) = display_info { @@ -819,11 +822,11 @@ pub(crate) async fn commit_prepared_event(prepared: PreparedEvent, is_new: bool) match result { RumorProcessingResult::TextMessage(mut msg) => { msg.wrapper_event_id = Some(wrapper_event_id.clone()); - handle_text_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes).await + handle_text_message(msg, &contact, is_mine, is_new, false, &wrapper_event_id, wrapper_event_id_bytes).await } RumorProcessingResult::FileAttachment(mut msg) => { msg.wrapper_event_id = Some(wrapper_event_id.clone()); - handle_file_attachment(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes).await + handle_file_attachment(msg, &contact, is_mine, is_new, false, &wrapper_event_id, wrapper_event_id_bytes).await } RumorProcessingResult::Reaction(reaction) => { let result = handle_reaction(reaction.clone(), &contact).await; diff --git a/src-tauri/src/services/subscription_handler.rs b/src-tauri/src/services/subscription_handler.rs index cb4c22e4..c9ffb632 100644 --- a/src-tauri/src/services/subscription_handler.rs +++ b/src-tauri/src/services/subscription_handler.rs @@ -167,8 +167,12 @@ pub(crate) async fn handle_mls_group_message(event: Event, my_public_key: Public } else { false }; let sender_dm_muted = state.get_chat(&sender_npub) .map_or(false, |c| c.muted); + let sender_blocked = state.get_profile(&sender_npub) + .map_or(false, |p| p.flags.is_blocked()); let notify = if let Some(chat) = state.get_chat(&group_id_for_persist) { - if mentions_me || everyone_ping { + if sender_blocked { + false + } else if mentions_me || everyone_ping { !message.mine && !sender_dm_muted } else { !message.mine && !chat.muted @@ -268,8 +272,12 @@ pub(crate) async fn handle_mls_group_message(event: Event, my_public_key: Public } else { false }; let sender_dm_muted = state.get_chat(&sender_npub) .map_or(false, |c| c.muted); + let sender_blocked = state.get_profile(&sender_npub) + .map_or(false, |p| p.flags.is_blocked()); let notify = if let Some(chat) = state.get_chat(&group_id_for_persist) { - if mentions_me || everyone_ping { + if sender_blocked { + false + } else if mentions_me || everyone_ping { !message.mine && !sender_dm_muted } else { !message.mine && !chat.muted @@ -647,13 +655,21 @@ pub(crate) async fn handle_mls_group_message(event: Event, my_public_key: Public .unwrap_or(None); if let Some(record) = emit_record { + let sender_blocked = { + let state = crate::STATE.lock().await; + record.npub.as_ref().and_then(|npub| state.get_profile(npub)) + .map_or(false, |p| p.flags.is_blocked()) + }; if let Some(handle) = TAURI_APP.get() { + // Always emit so the message renders in the group chat let _ = handle.emit("mls_message_new", serde_json::json!({ "group_id": group_id_for_emit, "message": record })); - // Update OS badge counter for group messages - let _ = commands::messaging::update_unread_counter(handle.clone()).await; + // Only update unread badge for non-blocked senders + if !sender_blocked { + let _ = commands::messaging::update_unread_counter(handle.clone()).await; + } } } diff --git a/src-tauri/src/state/chat_state.rs b/src-tauri/src/state/chat_state.rs index e70e0b08..ba3f63e2 100644 --- a/src-tauri/src/state/chat_state.rs +++ b/src-tauri/src/state/chat_state.rs @@ -500,6 +500,16 @@ impl ChatState { continue; } + // Skip DM chats with blocked users entirely + let is_group = chat.is_mls_group(); + if !is_group { + if let Some(id) = self.interner.lookup(&chat.id) { + if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { + continue; + } + } + } + let mut unread_count = 0; for msg in chat.iter_compact().rev() { if msg.flags.is_mine() { @@ -508,6 +518,12 @@ impl ChatState { if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; } + // In group chats, skip messages from blocked members + if is_group && msg.npub_idx != crate::message::compact::NO_NPUB { + if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { + continue; + } + } unread_count += 1; } diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 838c07a9..82de9449 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -148,7 +148,7 @@ pub fn get_file_type_description(extension: &str) -> String { // Text Files map.insert("txt", "Text File"); - map.insert("md", "Markdown"); + map.insert("md", "Markdown File"); map.insert("log", "Log File"); map.insert("csv", "CSV File"); map.insert("tsv", "TSV File"); diff --git a/src/icons/dots-horizontal.svg b/src/icons/dots-horizontal.svg new file mode 100644 index 00000000..ee52fe66 --- /dev/null +++ b/src/icons/dots-horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/index.html b/src/index.html index d9c28cde..4e083732 100644 --- a/src/index.html +++ b/src/index.html @@ -577,10 +577,6 @@

-
- - -
@@ -589,6 +585,20 @@

+
+ + + +
@@ -901,6 +911,17 @@

Privacy

+ +
+

+ + Blocked Users +

+ +
diff --git a/src/js/misc.js b/src/js/misc.js index 758b80eb..f893133c 100644 --- a/src/js/misc.js +++ b/src/js/misc.js @@ -634,7 +634,7 @@ function getFileTypeInfo(extension) { // Text Files "txt": { description: "Text File", icon: "file" }, - "md": { description: "Markdown", icon: "file" }, + "md": { description: "Markdown File", icon: "file" }, "log": { description: "Log File", icon: "file" }, "csv": { description: "CSV File", icon: "file" }, "tsv": { description: "TSV File", icon: "file" }, diff --git a/src/js/settings.js b/src/js/settings.js index 6489f6f6..fa8799cb 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -1623,6 +1623,66 @@ async function saveCurrentNotificationSettings() { } } +/** + * Load and render the blocked users list in Privacy settings + */ +async function loadBlockedUsersList() { + const listContainer = document.getElementById('settings-blocked-list'); + const emptyMsg = document.getElementById('settings-blocked-empty'); + listContainer.innerHTML = ''; + + try { + const blocked = await invoke('get_blocked_users'); + emptyMsg.style.display = blocked.length ? 'none' : ''; + + for (const profile of blocked) { + const row = document.createElement('div'); + row.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 8px 10px;'; + + const left = document.createElement('div'); + left.style.cssText = 'display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; -webkit-user-select: none; user-select: none;'; + + const avatar = createAvatarImg(getProfileAvatarSrc(profile), 30, false); + avatar.style.flexShrink = '0'; + const name = document.createElement('span'); + name.style.cssText = 'color: #ddd; font-size: 14px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;'; + const displayName = profile.nickname || profile.name; + if (displayName) { + name.textContent = displayName + ' '; + const npubHint = document.createElement('span'); + npubHint.style.cssText = 'opacity: 0.4; font-size: 12px;'; + npubHint.textContent = '(' + profile.id.substring(0, 8) + ')'; + name.appendChild(npubHint); + } else { + name.textContent = profile.id.substring(0, 20) + '...'; + } + + left.appendChild(avatar); + left.appendChild(name); + + const unblockBtn = document.createElement('span'); + unblockBtn.textContent = 'Unblock'; + unblockBtn.style.cssText = 'color: #ff4444; font-size: 13px; cursor: pointer; padding: 4px 10px; border: 1px solid #ff4444; border-radius: 6px;'; + unblockBtn.onmouseenter = () => { unblockBtn.style.backgroundColor = '#ff444420'; }; + unblockBtn.onmouseleave = () => { unblockBtn.style.backgroundColor = ''; }; + unblockBtn.onclick = async () => { + const confirmed = await popupConfirm('Unblock User', `Are you sure you want to unblock ${escapeHtml(profile.nickname || profile.name || profile.id.substring(0, 16))}?`); + if (!confirmed) return; + await invoke('unblock_user', { npub: profile.id }); + showToast('User unblocked'); + renderChatlist(); + await loadBlockedUsersList(); + }; + + row.appendChild(left); + row.appendChild(unblockBtn); + listContainer.appendChild(row); + } + } catch (e) { + console.warn('Failed to load blocked users:', e); + } +} + /** * Initialize settings on app start */ @@ -1655,6 +1715,23 @@ async function initSettings() { await saveSendTypingIndicators(e.target.checked); }); + // Load blocked users list + toggle + await loadBlockedUsersList(); + const blockedToggle = document.getElementById('settings-blocked-toggle'); + const blockedContent = document.getElementById('settings-blocked-content'); + const blockedChevron = blockedToggle.querySelector('.icon'); + blockedToggle.onclick = () => { + const isOpen = blockedContent.style.display !== 'none'; + if (isOpen) { + blockedContent.style.display = 'none'; + blockedChevron.style.transform = ''; + } else { + blockedContent.style.display = ''; + blockedContent.style.animation = 'blockedFadeIn 0.2s ease'; + blockedChevron.style.transform = 'rotate(180deg)'; + } + }; + // Load and initialize display settings fDisplayImageTypes = await loadDisplayImageTypes(); const displayImageTypesToggle = document.getElementById('display-image-types-toggle'); diff --git a/src/main.js b/src/main.js index 5e9a74d3..dd8adb47 100644 --- a/src/main.js +++ b/src/main.js @@ -57,10 +57,21 @@ const domProfileDescriptionEditor = document.getElementById('profile-description const domProfileOptions = document.getElementById('profile-option-list'); const domProfileOptionMessage = document.getElementById('profile-option-message'); const domProfileOptionMute = document.getElementById('profile-option-mute'); -const domProfileOptionNickname = document.getElementById('profile-option-nickname'); const domProfileOptionShare = document.getElementById('profile-option-share'); +const domProfileOptionMore = document.getElementById('profile-option-more'); +const domProfileMoreDropdown = document.getElementById('profile-more-dropdown'); +const domProfileOptionNickname = document.getElementById('profile-option-nickname'); +const domProfileOptionBlock = document.getElementById('profile-option-block'); const domProfileId = document.getElementById('profile-id'); +// Close profile "More" dropdown when clicking outside +document.addEventListener('click', () => { + if (domProfileMoreDropdown) { + domProfileMoreDropdown.style.display = 'none'; + domProfileOptionMore.classList.remove('active'); + } +}); + const domGroupOverview = document.getElementById('group-overview'); const domGroupOverviewBackBtn = document.getElementById('group-overview-back-btn'); const domGroupOverviewName = document.getElementById('group-overview-name'); @@ -2761,6 +2772,8 @@ let arrMLSInvites = []; * The current open chat (by npub) */ let strOpenChat = ""; +/** Blocked message IDs the user has clicked to reveal (survives re-renders) */ +const revealedBlockedMessages = new Set(); /** * The chat ID we came from when opening a profile (to return to on back) @@ -3336,6 +3349,7 @@ function generateChatlistStateHash() { profile?.avatar, profile?.avatar_cached, chat.muted, + profile?.is_blocked, isGroup ? chat.metadata?.avatar_cached : undefined, isGroup ? chat.metadata?.custom_fields?.name : undefined ); @@ -3378,6 +3392,12 @@ function renderChatlist() { // Do not render our own profile: it is accessible via the Bookmarks/Notes section if (chat.id === strPubkey) continue; + // Hide DM chats with blocked users from the chat list + if (chat.chat_type !== 'MlsGroup') { + const chatProfile = getProfile(chat.id); + if (chatProfile?.is_blocked) continue; + } + const divContact = renderChat(chat, primaryColor); fragment.appendChild(divContact); } @@ -3517,11 +3537,17 @@ function renderInviteItem(invite, primaryColor) { * @returns {string|null} - The typing text, or null if no one is typing */ function generateTypingText(chat) { - const activeTypers = chat.active_typers || []; + let activeTypers = chat.active_typers || []; if (activeTypers.length === 0) return null; const isGroup = chat.chat_type === 'MlsGroup'; + // Filter out blocked users from typing indicators in group chats + if (isGroup) { + activeTypers = activeTypers.filter(npub => !getProfile(npub)?.is_blocked); + if (activeTypers.length === 0) return null; + } + // DMs just show "Typing..." since we already know who it is if (!isGroup) return 'Typing...'; @@ -3572,7 +3598,22 @@ function resolveMentionText(text) { */ function generateChatPreviewText(chat) { const isGroup = chat.chat_type === 'MlsGroup'; - const cLastMsg = chat.messages[chat.messages.length - 1]; + + // In group chats, skip messages from blocked users for the preview + let cLastMsg = null; + if (isGroup) { + for (let i = chat.messages.length - 1; i >= 0; i--) { + const m = chat.messages[i]; + if (m.npub && !m.mine) { + const authorProfile = getProfile(m.npub); + if (authorProfile?.is_blocked) continue; + } + cLastMsg = m; + break; + } + } else { + cLastMsg = chat.messages[chat.messages.length - 1]; + } // Handle typing indicators const typingText = generateTypingText(chat); @@ -3849,6 +3890,12 @@ function countUnreadMessages(chat) { break; } + // Skip messages from blocked users in group chats + if (chat.chat_type === 'MlsGroup' && msg.npub) { + const authorProfile = getProfile(msg.npub); + if (authorProfile?.is_blocked) continue; + } + // Count this message as unread unreadCount++; } @@ -6116,16 +6163,6 @@ function renderProfileTab(cProfile) { // Setup Message option domProfileOptionMessage.onclick = () => openChat(cProfile.id); - // Setup Nickname option - domProfileOptionNickname.onclick = async () => { - const nick = await popupConfirm('Choose a Nickname', '', false, 'Nickname'); - // Check if they cancelled the nicknaming (resetting a nickname with an empty '' result is fine, though) - if (nick === false) return; - // Ensure it's not massive - if (nick.length >= 30) return popupConfirm('Woah woah!', 'A ' + nick.length + '-character nickname seems excessive!', true, '', 'vector_warning.svg'); - await invoke('set_nickname', { npub: cProfile.id, nickname: nick }); - } - // Setup Share option domProfileOptionShare.onclick = () => { const npub = document.getElementById('profile-npub')?.dataset.fullNpub; @@ -6143,6 +6180,48 @@ function renderProfileTab(cProfile) { } }; + // Setup Block option (inside More dropdown) + const isBlocked = cProfile.is_blocked || false; + const blockIcon = domProfileOptionBlock.querySelector('.icon'); + const blockLabel = domProfileOptionBlock.querySelector('span:first-child'); + blockIcon.style.backgroundColor = '#ff4444'; + if (blockLabel) { + blockLabel.style.color = '#ff4444'; + blockLabel.textContent = isBlocked ? 'Unblock' : 'Block'; + } + domProfileOptionBlock.onclick = async () => { + domProfileMoreDropdown.style.display = 'none'; + if (isBlocked) { + await invoke('unblock_user', { npub: cProfile.id }); + showToast('User unblocked'); + renderChatlist(); + } else { + const confirmed = await popupConfirm('Block User', 'Are you sure you want to block this user? You will no longer receive DMs from them.'); + if (!confirmed) return; + await invoke('block_user', { npub: cProfile.id }); + showToast('User blocked'); + renderChatlist(); + } + }; + + // Setup Nickname option (inside More dropdown) + domProfileOptionNickname.onclick = async () => { + domProfileMoreDropdown.style.display = 'none'; + const nick = await popupConfirm('Choose a Nickname', '', false, 'Nickname'); + if (nick === false) return; + if (nick.length >= 30) return popupConfirm('Woah woah!', 'A ' + nick.length + '-character nickname seems excessive!', true, '', 'vector_warning.svg'); + await invoke('set_nickname', { npub: cProfile.id, nickname: nick }); + }; + + // Setup More dropdown toggle + domProfileMoreDropdown.style.display = 'none'; + domProfileOptionMore.onclick = (e) => { + e.stopPropagation(); + const isOpen = domProfileMoreDropdown.style.display !== 'none'; + domProfileMoreDropdown.style.display = isOpen ? 'none' : 'block'; + domProfileOptionMore.classList.toggle('active', !isOpen); + }; + // Hide edit buttons and own-profile share document.querySelector('.profile-avatar-edit').style.display = 'none'; document.querySelector('.profile-banner-edit').style.display = 'none'; @@ -7094,7 +7173,7 @@ function createFileBox(cAttachment, state = 'downloaded') { // Create the text container span const textContainerSpan = document.createElement('span'); textContainerSpan.style.color = 'rgba(255, 255, 255, 0.85)'; - textContainerSpan.style.marginLeft = isMiniApp ? '15px' : '50px'; + textContainerSpan.style.marginLeft = isMiniApp ? '15px' : '60px'; textContainerSpan.style.lineHeight = '1.2'; textContainerSpan.style.minWidth = '0'; @@ -7741,6 +7820,35 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { } } + // In group chats, filter messages from blocked users with a click-to-reveal placeholder + const blockedAuthorNpub = isGroupChat && !msg.mine ? (msg.npub || sender?.id || '') : ''; + const blockedAuthorProfile = blockedAuthorNpub ? getProfile(blockedAuthorNpub) : null; + const isRevealedBlockedMsg = blockedAuthorProfile?.is_blocked && revealedBlockedMessages.has(msg.id); + if (blockedAuthorProfile?.is_blocked && !revealedBlockedMessages.has(msg.id)) { + // Placeholder + const blockedSpan = document.createElement('span'); + blockedSpan.style.cssText = 'color: rgba(255, 255, 255, 0.3); font-style: italic; cursor: pointer; display: flex; align-items: center; gap: 5px;'; + const blockedIcon = document.createElement('span'); + blockedIcon.classList.add('icon', 'icon-cancel'); + blockedIcon.style.cssText = 'width: 14px; height: 14px; position: relative; margin: 0; flex-shrink: 0; background-color: rgba(255, 255, 255, 0.3);'; + blockedSpan.appendChild(blockedIcon); + blockedSpan.appendChild(document.createTextNode('Blocked message')); + + blockedSpan.onclick = (e) => { + e.stopPropagation(); + revealedBlockedMessages.add(msg.id); + // Re-open the chat so the message renders through the full pipeline + openChat(strOpenChat); + }; + + pMessage.append(blockedSpan); + pMessage.classList.add('no-background'); + pMessage.style.borderRadius = '0'; + pMessage.style.overflow = 'visible'; + divMessage.appendChild(pMessage); + return divMessage; + } + // Pre-detect npub to potentially modify displayed content // If npub is at the end of the message, we'll strip it from the text display const npubInfoEarly = detectNostrProfile(msg.content); @@ -7768,7 +7876,8 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { linkifyUrls(spanMessage); // Process inline image URLs (async - will load images in background) - processInlineImages(spanMessage); + // Skip for revealed blocked messages to prevent IP leaks via rogue image URLs + if (!isRevealedBlockedMsg) processInlineImages(spanMessage); // Render @npub1... mentions as highlighted display names const senderNpub = msg.mine ? strPubkey : (msg.npub || ''); @@ -8159,8 +8268,8 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { pMessage.appendChild(fileDiv); } } else { - // Check if this attachment will auto-download (skip if previously failed) - const willAutoDownload = cAttachment.size > 0 && cAttachment.size <= MAX_AUTO_DOWNLOAD_BYTES && !cAttachment.download_failed; + // Check if this attachment will auto-download (skip if previously failed or from a revealed blocked user) + const willAutoDownload = !isRevealedBlockedMsg && cAttachment.size > 0 && cAttachment.size <= MAX_AUTO_DOWNLOAD_BYTES && !cAttachment.download_failed; // For images, show thumbhash preview with download button (unless auto-downloading) if (['png', 'jpeg', 'jpg', 'gif', 'webp', 'tiff', 'tif', 'ico'].includes(cAttachment.extension)) { @@ -8371,7 +8480,7 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { // Append Metadata Previews (i.e: OpenGraph data from URLs, etc) - only if enabled // Skip web preview if we already rendered a profile preview (e.g., vectorapp.io/profile links) const skipWebPreview = npubInfoEarly && npubInfoEarly.type === 'link'; - if (!msg.pending && !msg.failed && fWebPreviewsEnabled && !skipWebPreview) { + if (!msg.pending && !msg.failed && fWebPreviewsEnabled && !skipWebPreview && !isRevealedBlockedMsg) { // Check if we have metadata with either an image OR a title/description const hasMetadata = msg.preview_metadata && ( msg.preview_metadata.og_image || @@ -8537,8 +8646,9 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { divExtras.style.marginTop = `25px`; } - // These can ONLY be shown on fully sent messages (inherently does not apply to received msgs) - if (!msg.pending && !msg.failed) { + // These can ONLY be shown on fully sent messages, and not in blocked DM chats + const isBlockedDM = !isGroupChat && sender?.is_blocked; + if (!msg.pending && !msg.failed && !isBlockedDM) { // Reactions if (spanReaction) { if (msg.mine) { @@ -8670,6 +8780,11 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { } }, 0); + // Revealed blocked messages render normally but at reduced opacity + if (isRevealedBlockedMsg) { + divMessage.style.opacity = '0.4'; + } + return divMessage; } @@ -8978,6 +9093,11 @@ async function openChat(contact) { // Hide the Navbar domNavbar.style.display = `none`; + // Clear existing messages so they're fully re-rendered (picks up state changes like blocking) + domChatMessages.innerHTML = ''; + // Only reset revealed blocked messages when switching to a different chat + if (strOpenChat !== contact) revealedBlockedMessages.clear(); + // Warm up GIF server connection early (non-blocking) preconnectGifServer(); @@ -9098,7 +9218,32 @@ async function openChat(contact) { // Initialize procedural scroll state with actual counts initProceduralScrollWithCache(contact, initialMessages.length, totalMessages); - updateChat(chat, initialMessages, profile, true); + await updateChat(chat, initialMessages, profile, true); + + // If the user is blocked (DM only), disable the chat input and show a system message + const isBlockedChat = !isGroup && profile?.is_blocked; + // Remove any previous blocked notice before (re-)evaluating + document.getElementById('blocked-notice')?.remove(); + if (isBlockedChat) { + domChatMessageInput.disabled = true; + domChatMessageInput.placeholder = 'Unblock to send messages'; + domChatMessageInput.style.paddingLeft = '15px'; + domChatMessageInputFile.style.display = 'none'; + domChatMessageInputVoice.style.display = 'none'; + domChatMessageInputEmoji.style.display = 'none'; + // Append a system-style blocked notice at the bottom of the chat + const blockedNotice = insertSystemEvent('Blocked — You won\'t receive new messages from them'); + blockedNotice.id = 'blocked-notice'; + blockedNotice.style.marginBottom = '20px'; + domChatMessages.appendChild(blockedNotice); + } else { + domChatMessageInput.disabled = false; + domChatMessageInput.placeholder = 'Enter message...'; + domChatMessageInput.style.paddingLeft = ''; + domChatMessageInputFile.style.display = ''; + domChatMessageInputVoice.style.display = ''; + domChatMessageInputEmoji.style.display = ''; + } // Mark as read on open (needed for Windows where is_focused may not work) // Only mark the last contact message, not our own messages @@ -9119,7 +9264,7 @@ async function openChat(contact) { updateChatBackNotification(); // Focus chat input on desktop (mobile keyboards are intrusive) - if (!platformFeatures.is_mobile) { + if (!platformFeatures.is_mobile && !isBlockedChat) { domChatMessageInput.focus(); } } @@ -10228,6 +10373,9 @@ function openSettings() { // Update the Storage Breakdown initStorageSection(); + // Refresh blocked users list + loadBlockedUsersList(); + // Check primary device status when settings are opened checkPrimaryDeviceStatus(); @@ -11507,9 +11655,20 @@ document.addEventListener('click', (e) => { // If we're clicking an Attachment Download button, request the download if (e.target.hasAttribute('download')) { const attId = e.target.getAttribute('data-attachment-id'); + const dlNpub = e.target.getAttribute('npub'); + const dlMsgId = e.target.getAttribute('msg'); if (downloadingAttachmentIds.has(attId)) return; downloadingAttachmentIds.add(attId); - return invoke('download_attachment', { npub: e.target.getAttribute('npub'), msgId: e.target.getAttribute('msg'), attachmentId: attId }) + // Swap download button for a centered progress spinner overlay + const overlay = document.createElement('div'); + overlay.className = 'attachment-progress-overlay'; + const spinner = document.createElement('div'); + spinner.className = 'miniapp-downloading-spinner'; + spinner.setAttribute('data-attachment-id', attId); + spinner.style.cssText = 'width: 48px; height: 48px;'; + overlay.appendChild(spinner); + e.target.replaceWith(overlay); + return invoke('download_attachment', { npub: dlNpub, msgId: dlMsgId, attachmentId: attId }) .catch(() => downloadingAttachmentIds.delete(attId)); } diff --git a/src/styles.css b/src/styles.css index 9d792d26..5039754c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -933,6 +933,66 @@ html, body { opacity: 1; } +/* Profile "More" dropdown menu */ +.profile-more-dropdown { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + background: #1e1e1e; + border: 1px solid #393B3C; + border-radius: 10px; + padding: 0; + min-width: 150px; + overflow: hidden; + z-index: 10; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + opacity: 1 !important; +} + +#profile-option-more:hover, +#profile-option-more.active { + opacity: 1 !important; + background-color: #171717; + border-color: #959899; + color: white; +} + +.profile-more-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + color: #b2b2b2; + font-size: 14px; + white-space: nowrap; +} + +.profile-more-item .icon { + position: relative; + flex-shrink: 0; + margin: 0; +} + +.profile-more-item:hover { + background: #2a2a2a; + color: white; + opacity: 1 !important; +} + +.profile-more-item:first-child { + border-radius: 10px 10px 0 0; +} + +.profile-more-item:last-child { + border-radius: 0 0 10px 10px; +} + +@keyframes blockedFadeIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + /* Profile npub Section */ .profile-npub-container { display: flex; @@ -4147,6 +4207,10 @@ select:focus::-ms-value { mask-image: url("./icons/x-user.svg"); } +.icon-dots-horizontal { + mask-image: url("./icons/dots-horizontal.svg"); +} + /* For downloaded models - make it more prominent */ .btn-delete-model.downloaded { opacity: 0.8; From d78e381ce67d2f693cc1acde293c43204c3fa2c2 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Fri, 20 Mar 2026 16:04:22 +0000 Subject: [PATCH 006/417] fix: resolve Mini App realtime channel churn, tooltip sticking, and upload cancel alignment - joinRealtimeChannel returns existing channel instead of throwing on double-call, preventing the leave/rejoin cycle that destroyed preconnected channels (caused noisy WS errors and redundant peer advertisements in games like DOOM) - WebSocket fast-path retries once after 200ms on connection failure before falling back to invoke path (handles accept loop not yet polled) - Tooltips dismiss on click, tap, and window blur (fixes stuck tooltips when Mini App window takes focus) - Upload cancel button aligned to left:15px to match spinner center position Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/miniapps/scheme.rs | 52 ++++++++++++++++++++------------ src/main.js | 5 +++ src/styles.css | 2 +- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/miniapps/scheme.rs b/src-tauri/src/miniapps/scheme.rs index 3e3fa9c6..d54165ef 100644 --- a/src-tauri/src/miniapps/scheme.rs +++ b/src-tauri/src/miniapps/scheme.rs @@ -541,7 +541,7 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String joinRealtimeChannel: function() {{ if (realtimeChannel !== null) {{ - throw new Error('Already joined realtime channel. Call leave() first.'); + return realtimeChannel; }} realtimeChannel = {{ @@ -590,28 +590,40 @@ fn generate_webxdc_bridge_js(user_npub: &str, user_display_name: &str) -> String }}).then(function(result) {{ // Open WebSocket fast-path if backend returned a URL if (result && result.ws_url) {{ - try {{ - var label = encodeURIComponent(window.__TAURI_INTERNALS__.metadata.currentWebview.label || ''); - rtWs = new WebSocket(result.ws_url + '/' + label); - rtWs.binaryType = 'arraybuffer'; - rtWs.onclose = function() {{ rtWs = null; }}; - rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; rtWsFailed = true; }}; - // Detect WebKitGTK silent WS block: if still CONNECTING after 1.5s, fall back to invoke - setTimeout(function() {{ - if (rtWs && rtWs.readyState === 0) {{ - console.warn('[webxdc] WebSocket stuck in CONNECTING — falling back to invoke'); + var wsUrl = result.ws_url; + var label = encodeURIComponent(window.__TAURI_INTERNALS__.metadata.currentWebview.label || ''); + var wsRetried = false; + function connectWs() {{ + try {{ + rtWs = new WebSocket(wsUrl + '/' + label); + rtWs.binaryType = 'arraybuffer'; + rtWs.onclose = function() {{ rtWs = null; }}; + rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; - rtWsFailed = true; - }} - }}, 1500); - }} catch(e) {{ - rtWs = null; - rtWsFailed = true; + // Retry once after 200ms (accept loop may not have polled yet) + if (!wsRetried) {{ + wsRetried = true; + setTimeout(connectWs, 200); + }} else {{ + rtWsFailed = true; + }} + }}; + // Detect WebKitGTK silent WS block: if still CONNECTING after 1.5s, fall back to invoke + setTimeout(function() {{ + if (rtWs && rtWs.readyState === 0) {{ + console.warn('[webxdc] WebSocket stuck in CONNECTING — falling back to invoke'); + try {{ rtWs.close(); }} catch(e) {{}} + rtWs = null; + rtWsFailed = true; + }} + }}, 1500); + }} catch(e) {{ + rtWs = null; + rtWsFailed = true; + }} }} - }} else {{ - console.warn('[webxdc] No ws_url returned — using invoke fallback'); - rtWsFailed = true; + connectWs(); }} }}).catch(function(err) {{ console.error('[webxdc] Failed to join realtime channel:', err); diff --git a/src/main.js b/src/main.js index dd8adb47..c031132f 100644 --- a/src/main.js +++ b/src/main.js @@ -353,6 +353,11 @@ function hideGlobalTooltip() { tooltip.classList.remove('visible'); } +// Dismiss stuck tooltips on any click/tap or window blur +document.addEventListener('click', hideGlobalTooltip); +document.addEventListener('touchstart', hideGlobalTooltip); +window.addEventListener('blur', hideGlobalTooltip); + /** * Shows the main attachment panel view (File, Mini Apps buttons) */ diff --git a/src/styles.css b/src/styles.css index 5039754c..54ca0064 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9123,7 +9123,7 @@ hr { /* Cancel button for file box uploads — positioned over the spinner inside .custom-audio-player */ .custom-audio-player > .upload-cancel-btn { position: absolute; - left: 10px; + left: 15px; top: 0; bottom: 0; margin: auto; From 1f306e586ee062ccfe60920b96245a3bd32c0ce2 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Fri, 20 Mar 2026 22:00:09 +0000 Subject: [PATCH 007/417] Fix Android WebXDC realtime: bi-directional WS, subscribe race, cleanup Multiple fixes for intermittent one-way data flow on Android: 1. Subscribe-before-connect race fix (realtime.rs): Move peer connect() AFTER subscribe_with_opts() so the gossip actor has the topic registered when connections deliver messages. 2. Bi-directional WebSocket receive (rt_ws.rs, realtime.rs): WS was send-only. Now incoming gossip data is pushed back through the WS connection, bypassing JNI evaluateJavascript starvation. 3. Bootstrap peers in preconnect (commands.rs): Pass cached + persisted peer addresses to join_channel as bootstrap instead of joining with 0 peers and racing with try_add_peer. 4. Android overlay cleanup (commands.rs): miniapp_close and stale instance cleanup now properly tear down realtime channels, session peers, and WS senders. 5. Android CSP (MiniAppWebViewClient.kt): Added ws://127.0.0.1:* to connect-src and wasm-unsafe-eval to script-src to match desktop CSP. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vectorapp/miniapp/MiniAppWebViewClient.kt | 2 +- src-tauri/src/android/miniapp_jni.rs | 14 ++- src-tauri/src/miniapps/commands.rs | 92 +++++++++++++++++-- src-tauri/src/miniapps/realtime.rs | 88 +++++++++++++++--- src-tauri/src/miniapps/rt_ws.rs | 51 ++++++++-- 5 files changed, 212 insertions(+), 35 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/io/vectorapp/miniapp/MiniAppWebViewClient.kt b/src-tauri/gen/android/app/src/main/java/io/vectorapp/miniapp/MiniAppWebViewClient.kt index c73901c2..3e1a8060 100644 --- a/src-tauri/gen/android/app/src/main/java/io/vectorapp/miniapp/MiniAppWebViewClient.kt +++ b/src-tauri/gen/android/app/src/main/java/io/vectorapp/miniapp/MiniAppWebViewClient.kt @@ -45,7 +45,7 @@ class MiniAppWebViewClient( * * This matches the desktop implementation for consistency. */ - private const val CSP = """default-src 'self' http://webxdc.localhost; style-src 'self' http://webxdc.localhost 'unsafe-inline' blob:; font-src 'self' http://webxdc.localhost data: blob:; script-src 'self' http://webxdc.localhost 'unsafe-inline' 'unsafe-eval' blob:; connect-src 'self' http://webxdc.localhost ipc: data: blob:; img-src 'self' http://webxdc.localhost data: blob:; media-src 'self' http://webxdc.localhost data: blob:; webrtc 'block'""" + private const val CSP = """default-src 'self' http://webxdc.localhost; style-src 'self' http://webxdc.localhost 'unsafe-inline' blob:; font-src 'self' http://webxdc.localhost data: blob:; script-src 'self' http://webxdc.localhost 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:; connect-src 'self' http://webxdc.localhost ipc: data: blob: ws://127.0.0.1:*; img-src 'self' http://webxdc.localhost data: blob:; media-src 'self' http://webxdc.localhost data: blob:; webrtc 'block'""" /** * Permissions Policy that denies all sensitive APIs by default. diff --git a/src-tauri/src/android/miniapp_jni.rs b/src-tauri/src/android/miniapp_jni.rs index 5e99c92e..e51fdc9a 100644 --- a/src-tauri/src/android/miniapp_jni.rs +++ b/src-tauri/src/android/miniapp_jni.rs @@ -459,7 +459,8 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative // Join the channel with mpsc event target let event_target = crate::miniapps::realtime::EventTarget::MpscSender(tx); - let is_rejoin = match iroh.join_channel(topic, vec![], Some(event_target), Some(app_for_join.clone()), miniapp_id_for_join.clone()).await { + let ws_targets = Some(state.realtime.ws_senders.clone()); + let is_rejoin = match iroh.join_channel(topic, vec![], Some(event_target), Some(app_for_join.clone()), miniapp_id_for_join.clone(), ws_targets).await { Ok((rejoin, _)) => { if rejoin { log_info!("[WEBXDC] Android: Re-joined existing channel for topic: {}", topic_encoded_for_join); @@ -1279,6 +1280,17 @@ fn generate_android_webxdc_bridge(self_addr: &str, self_name: &str) -> String { rtWs.binaryType = 'arraybuffer'; rtWs.onclose = function() {{ rtWs = null; }}; rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }}; + // Bi-directional: receive gossip data via WS (bypasses JNI starvation) + rtWs.onmessage = function(ev) {{ + var fn_listener = window.__miniapp_realtime_listener; + if (fn_listener && ev.data) {{ + // Data arrives as base91 string in binary frame + var bytes = new Uint8Array(ev.data); + var str = ''; + for (var i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]); + fn_listener(new Uint8Array(b91d(str))); + }} + }}; // Detect stuck CONNECTING state (some WebViews silently block WS) setTimeout(function() {{ if (rtWs && rtWs.readyState === 0) {{ diff --git a/src-tauri/src/miniapps/commands.rs b/src-tauri/src/miniapps/commands.rs index 949a613b..76b1f08e 100644 --- a/src-tauri/src/miniapps/commands.rs +++ b/src-tauri/src/miniapps/commands.rs @@ -696,8 +696,9 @@ pub async fn miniapp_open( href: Option, topic_id: Option, ) -> Result<(), Error> { + log_info!("[WEBXDC] miniapp_open called: chat={}, msg={}", chat_id, message_id); let path = PathBuf::from(&file_path); - + // Generate unique ID from file hash let id = format!("miniapp_{:x}", md5_hash(&file_path)); // For marketplace apps (empty chat/message), use the app id as the window label @@ -724,8 +725,29 @@ pub async fn miniapp_open( } return Ok(()); } else { - // Overlay was closed, clean up state - log_warn!("Instance exists but overlay closed, cleaning up: {}", existing_label); + // Overlay was closed but state was never cleaned up. + // Do full teardown: realtime channel, session peers, instance. + log_warn!("Instance exists but overlay closed, full cleanup: {}", existing_label); + + let channel_state = state.remove_realtime_channel(&existing_label).await; + if let Some(channel) = channel_state { + let topic_encoded = super::realtime::encode_topic_id(&channel.topic); + if let Ok(iroh) = state.realtime.get_or_init().await { + if let Err(e) = iroh.leave_channel(channel.topic, &existing_label).await { + log_warn!("[WEBXDC] Stale cleanup: leave_channel failed: {}", e); + } + } + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + if let Ok(my_npub) = my_pk.to_bech32() { + state.remove_session_peer(&channel.topic, &my_npub).await; + } + } + let chat_id_clone = chat_id.clone(); + tokio::spawn(async move { + crate::commands::realtime::send_webxdc_peer_left(chat_id_clone, topic_encoded).await; + }); + } + state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&existing_label); state.remove_instance(&existing_label).await; } } @@ -833,11 +855,32 @@ pub async fn miniapp_open( Err(e) => { log_warn!("[WEBXDC] Preconnect: Iroh init failed: {e}"); return; } }; - // Create gossip channel with NO event target. Incoming data is BUFFERED - // (not dropped) until joinRealtimeChannel sets the target and flushes. - // This lets us form the gossip mesh NOW so peers are connected before - // the game starts its election/handshake. - if let Err(e) = iroh.join_channel(topic, vec![], None, Some(app_pc.clone()), label_pc.clone()).await { + // Collect any cached peer addresses (from advertisements that arrived + // before we opened) + persisted peers from the DB to use as bootstrap. + let mut bootstrap_peers: Vec = Vec::new(); + + // Cached from recent Nostr advertisements + let cached = state.take_peer_addrs(&topic).await; + bootstrap_peers.extend(cached); + + // Persisted from DB + let my_npub = crate::MY_PUBLIC_KEY.get() + .and_then(|pk| nostr_sdk::prelude::ToBech32::to_bech32(pk).ok()) + .unwrap_or_default(); + if let Ok(records) = crate::db::get_active_peer_advertisements(&topic_encoded, &my_npub) { + for record in &records { + if let Ok(addr) = super::realtime::decode_node_addr(&record.node_addr_encoded) { + bootstrap_peers.push(addr); + } + } + } + + log_info!("[WEBXDC] Preconnect: joining with {} bootstrap peers", bootstrap_peers.len()); + + // Create gossip channel with bootstrap peers and NO event target. + // Incoming data is BUFFERED (not dropped) until joinRealtimeChannel + // sets the target and flushes. + if let Err(e) = iroh.join_channel(topic, bootstrap_peers, None, Some(app_pc.clone()), label_pc.clone(), None).await { log_warn!("[WEBXDC] Preconnect: join_channel failed: {e}"); return; } @@ -1163,6 +1206,36 @@ pub async fn miniapp_close( } } + // Full teardown: remove channel state, leave gossip, clean up + // (Desktop does this in WindowEvent::Destroyed, but Android has no + // Tauri window, so we must do it here explicitly) + let channel_state = state.remove_realtime_channel(&label).await; + if let Some(channel) = channel_state { + let topic_encoded = super::realtime::encode_topic_id(&channel.topic); + + if let Ok(iroh) = state.realtime.get_or_init().await { + if let Err(e) = iroh.leave_channel(channel.topic, &label).await { + log_warn!("[WEBXDC] Failed to leave channel on close: {}", e); + } + } + + // Remove ourselves from session peers + if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { + if let Ok(my_npub) = my_pk.to_bech32() { + state.remove_session_peer(&channel.topic, &my_npub).await; + } + } + + // Send peer-left via Nostr + let chat_id_clone = chat_id.clone(); + tokio::spawn(async move { + crate::commands::realtime::send_webxdc_peer_left(chat_id_clone, topic_encoded).await; + }); + } + + // Clean up WS sender + state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&label); + state.remove_instance(&label).await; } @@ -1306,7 +1379,8 @@ pub async fn miniapp_join_realtime_channel( // Create the gossip channel WITH the event target — no data can be dropped let event_target = EventTarget::TauriChannel(channel); - let (is_rejoin, _) = iroh.join_channel(topic, vec![], Some(event_target), Some(app.clone()), label.to_string()).await + let ws_targets = Some(state.realtime.ws_senders.clone()); + let (is_rejoin, _) = iroh.join_channel(topic, vec![], Some(event_target), Some(app.clone()), label.to_string(), ws_targets).await .map_err(|e| Error::RealtimeError(e.to_string()))?; let topic_encoded_clone = topic_encoded.clone(); diff --git a/src-tauri/src/miniapps/realtime.rs b/src-tauri/src/miniapps/realtime.rs index 0a2ba519..8d847e71 100644 --- a/src-tauri/src/miniapps/realtime.rs +++ b/src-tauri/src/miniapps/realtime.rs @@ -254,6 +254,7 @@ impl IrohState { event_target: Option, app_handle: Option, label: String, + ws_event_targets: Option>>>>>, ) -> Result<(bool, Option>)> { let mut channels = self.channels.write().await; @@ -265,6 +266,15 @@ impl IrohState { let mut state = channel_state.event_target.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }); state.set_target(target); // Flushes buffered events from preconnect phase } + // Wire up WS sender for bi-directional receive (if WS is connected) + if let Some(ref senders) = ws_event_targets { + let map = senders.read().unwrap_or_else(|e| e.into_inner()); + if let Some(ws_tx) = map.get(&label) { + let mut state = channel_state.event_target.write().unwrap_or_else(|e| e.into_inner()); + state.set_ws_sender(ws_tx.clone()); + log_info!("[WEBXDC] RT WS bi-directional enabled for: {label}"); + } + } return Ok((true, None)); } @@ -276,7 +286,20 @@ impl IrohState { peer_ids.len() ); - // Connect to peers so gossip can discover them + // DON'T manually connect + handle_connection here — that creates + // connections BEFORE the topic subscription exists, causing a race + // where messages arrive for an unregistered topic and get lost. + // Instead, connect AFTER subscribing, so the gossip actor has the + // topic registered when the connection delivers messages. + + let (join_tx, join_rx) = oneshot::channel(); + + let gossip_topic = self + .gossip + .subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids)) + .await?; + + // NOW connect — topic subscription is registered, safe to receive for peer_addr in &peers { if !peer_addr.addrs.is_empty() { let addr = peer_addr.clone(); @@ -294,19 +317,22 @@ impl IrohState { }); } } - - let (join_tx, join_rx) = oneshot::channel(); - - let gossip_topic = self - .gossip - .subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids)) - .await?; let (gossip_sender, gossip_receiver) = gossip_topic.split(); // Create shared event target for the subscribe loop (buffers events if target is None) let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(EventTargetState::new(event_target))); let shared_target_clone = shared_event_target.clone(); + // Wire up WS sender for bi-directional receive (if WS is connected) + if let Some(ref senders) = ws_event_targets { + let map = senders.read().unwrap_or_else(|e| e.into_inner()); + if let Some(ws_tx) = map.get(&label) { + let mut state = shared_event_target.write().unwrap_or_else(|e| e.into_inner()); + state.set_ws_sender(ws_tx.clone()); + log_info!("[WEBXDC] RT WS bi-directional enabled for: {label}"); + } + } + // Create shared peer count let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0)); let peer_count_clone = shared_peer_count.clone(); @@ -391,11 +417,12 @@ impl IrohState { log_trace!("[WEBXDC] add_peer: Connecting to peer {}", peer_addr.id); - // Connect to the peer and hand the connection to gossip + // Connect and hand to gossip, then join_peers. + // Topic subscription already exists (channel is in the map), + // so the connection won't race with topic registration. let conn = self.endpoint.connect(peer_addr, GOSSIP_ALPN).await?; self.gossip.handle_connection(conn).await?; - // Join the peer to the existing gossip topic let channels = self.channels.read().await; if let Some(channel_state) = channels.get(topic) { channel_state.sender.join_peers(vec![peer.id]).await?; @@ -459,6 +486,9 @@ impl IrohState { // 1. Remove fast-path SendHandle (drops its GossipSender clone) self.send_handles.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }).remove(label); + // Remove WS sender for this label (ws_senders is on RealtimeManager, + // but we're on IrohState — caller handles this separately) + if let Some(channel) = self.channels.write().await.remove(&topic) { // 2. Drop the ChannelState's sender explicitly (don't wait for implicit drop) drop(channel.sender); @@ -467,7 +497,7 @@ impl IrohState { channel.subscribe_loop.abort(); let _ = channel.subscribe_loop.await; - // 4. Small yield to let the runtime fully clean up dropped tasks + // 4. Yield to let the gossip actor process the quit tokio::task::yield_now().await; log_info!("Left realtime channel {:?}", topic); @@ -515,19 +545,42 @@ pub enum EventTarget { pub(crate) struct EventTargetState { target: Option, buffer: Vec, + /// Optional WebSocket sender for bi-directional WS (bypasses JNI on Android). + /// When set, Data events are sent directly through WS instead of the normal target. + ws_sender: Option>>, } impl EventTargetState { fn new(target: Option) -> Self { - Self { target, buffer: Vec::new() } + Self { target, buffer: Vec::new(), ws_sender: None } } - /// Send an event, buffering if no target is set yet + /// Register a WS sender for bi-directional receive. Data events bypass + /// the normal target (JNI on Android) and go straight through WebSocket. + pub fn set_ws_sender(&mut self, sender: tokio::sync::mpsc::Sender>) { + self.ws_sender = Some(sender); + } + + pub fn clear_ws_sender(&mut self) { + self.ws_sender = None; + } + + /// Send an event, buffering if no target is set yet. + /// Data events are routed through WebSocket when available (bypasses JNI on Android). fn send(&mut self, event: RealtimeEvent) -> bool { + // If WS sender is available and this is a Data event, send via WS directly. + // This bypasses the JNI/evaluateJavascript path that gets starved by WASM. + if let Some(ref ws_tx) = self.ws_sender { + if let RealtimeEvent::Data(ref b91_data) = event { + // Send raw base91 string as binary WS frame + let _ = ws_tx.try_send(b91_data.as_bytes().to_vec()); + return true; + } + } + if let Some(ref target) = self.target { Self::deliver(target, event) } else { - // Buffer up to 256 events (prevents unbounded memory if target is never set) if self.buffer.len() < 256 { self.buffer.push(event); } @@ -764,6 +817,9 @@ pub struct RealtimeManager { /// Fast-path send handles — owned here so the WS server can start /// before IrohState exists (critical for Android JNI timing). send_handles: Arc>>, + /// Map of window_label → WS sender for bi-directional receive. + /// WS handler registers sender on connect, join_channel wires it into the event target. + pub(crate) ws_senders: Arc>>>>, } impl RealtimeManager { @@ -773,6 +829,7 @@ impl RealtimeManager { relay_url, ws_info: std::sync::OnceLock::new(), send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())), + ws_senders: Arc::new(std::sync::RwLock::new(HashMap::new())), } } @@ -831,6 +888,7 @@ impl RealtimeManager { // Spawn accept loop on the MAIN Tauri runtime (survives JNI temp runtime) let send_handles = self.send_handles.clone(); + let ws_senders = self.ws_senders.clone(); tauri::async_runtime::spawn(async move { // Convert std listener to tokio listener on the main runtime let listener = match tokio::net::TcpListener::from_std(std_listener) { @@ -840,7 +898,7 @@ impl RealtimeManager { return; } }; - super::rt_ws::run_accept_loop(listener, token, send_handles).await; + super::rt_ws::run_accept_loop(listener, token, send_handles, ws_senders).await; }); } diff --git a/src-tauri/src/miniapps/rt_ws.rs b/src-tauri/src/miniapps/rt_ws.rs index 3d40eb1a..0eee77a5 100644 --- a/src-tauri/src/miniapps/rt_ws.rs +++ b/src-tauri/src/miniapps/rt_ws.rs @@ -13,7 +13,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::Message; -use super::realtime::SendHandle; +use super::realtime::{SendHandle, SharedEventTarget}; /// Info returned after starting the WS server. pub(crate) struct WsInfo { @@ -25,6 +25,9 @@ pub(crate) struct WsInfo { struct WsState { token: String, send_handles: Arc>>, + /// Map of window label → WS sender. WS handler registers here on connect. + /// join_channel looks up the sender and wires it into the event target. + ws_senders: Arc>>>>, } @@ -35,10 +38,12 @@ pub(crate) async fn run_accept_loop( listener: TcpListener, token: String, send_handles: Arc>>, + ws_senders: Arc>>>>, ) { let state = Arc::new(WsState { token, send_handles, + ws_senders, }); loop { @@ -113,19 +118,33 @@ async fn handle_connection( log_info!("[WEBXDC] RT WS connected: {label}"); - // Read loop: binary frames → fast_send, with periodic stats - let (mut _sink, mut stream) = ws_stream.split(); + // Bi-directional: read from JS (send to gossip) AND write to JS (receive from gossip) + let (mut sink, mut stream) = ws_stream.split(); + + // Create a channel for incoming gossip data → WS write + let (ws_tx, mut ws_rx) = tokio::sync::mpsc::channel::>(256); + + // Store WS sender in a shared map so join_channel can find it later. + // The WS connects before join_channel runs, so we can't look up the event + // target here. Instead, join_channel looks up our sender and wires it in. + { + let mut senders = state.ws_senders.write().unwrap_or_else(|e| e.into_inner()); + senders.insert(label.clone(), ws_tx.clone()); + log_info!("[WEBXDC] RT WS sender registered for: {label}"); + } + let mut msg_count: u64 = 0; let mut total_nanos: u64 = 0; let mut peak_nanos: u64 = 0; let mut total_bytes: u64 = 0; + let mut recv_count: u64 = 0; let mut stats_interval = tokio::time::interval(std::time::Duration::from_secs(5)); stats_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Skip the first immediate tick stats_interval.tick().await; loop { tokio::select! { + // JS → gossip (send path) msg = stream.next() => { match msg { Some(Ok(Message::Binary(data))) => { @@ -145,22 +164,30 @@ async fn handle_connection( } } Some(Ok(Message::Ping(payload))) => { - let _ = _sink.send(Message::Pong(payload)).await; + let _ = sink.send(Message::Pong(payload)).await; } Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, _ => {} } } + // Gossip → JS (receive path via WS) + Some(data) = ws_rx.recv() => { + if sink.send(Message::Binary(data.into())).await.is_err() { + break; + } + recv_count += 1; + } _ = stats_interval.tick() => { - if msg_count > 0 { - let avg_us = (total_nanos / msg_count) / 1_000; + if msg_count > 0 || recv_count > 0 { + let avg_us = if msg_count > 0 { (total_nanos / msg_count) / 1_000 } else { 0 }; let peak_us = peak_nanos / 1_000; - let avg_kb = total_bytes / msg_count / 1024; + let avg_kb = if msg_count > 0 { total_bytes / msg_count / 1024 } else { 0 }; log_info!( - "[WEBXDC] RT WS stats: {msg_count} msgs in 5s ({}/s), avg {avg_us}μs, peak {peak_us}μs, avg {avg_kb}KB/msg", + "[WEBXDC] RT WS stats: {msg_count} sent/{recv_count} recv in 5s ({}/s), avg {avg_us}μs, peak {peak_us}μs, avg {avg_kb}KB/msg", msg_count / 5 ); msg_count = 0; + recv_count = 0; total_nanos = 0; peak_nanos = 0; total_bytes = 0; @@ -169,6 +196,12 @@ async fn handle_connection( } } + // Clean up WS sender + { + let mut senders = state.ws_senders.write().unwrap_or_else(|e| e.into_inner()); + senders.remove(&label); + } + log_info!("[WEBXDC] RT WS disconnected: {label}"); Ok(()) } From 134fa3eb848d46784e6c913fdac3e9547d92367f Mon Sep 17 00:00:00 2001 From: JSKitty Date: Sat, 21 Mar 2026 10:42:13 +0000 Subject: [PATCH 008/417] Fix Android Mini App session 2 connection failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destroy and recreate the entire Iroh instance on mini app close instead of just leaving the gossip channel. The old OnceCell persisted stale gossip subscriptions across sessions — the gossip actor's topic cleanup raced with the next session's subscribe_with_opts, creating a broken duplicate subscription (sends worked, receives didn't). Changes: - realtime.rs: OnceCell → RwLock>> with shutdown_iroh() that closes endpoint, gossip actor, and all channels - miniapp_jni.rs: onMiniAppClosed calls shutdown_iroh() instead of leave_channel() - commands.rs: stale cleanup and miniapp_close also use shutdown_iroh() on Android; desktop keeps leave_channel via WindowEvent::Destroyed - Removed iroh-gossip try_send() patch (not the root cause) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/android/miniapp_jni.rs | 50 ++++++--------- src-tauri/src/miniapps/commands.rs | 28 +++----- src-tauri/src/miniapps/realtime.rs | 95 +++++++++++++++++++++++----- 3 files changed, 108 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/android/miniapp_jni.rs b/src-tauri/src/android/miniapp_jni.rs index e51fdc9a..529d332e 100644 --- a/src-tauri/src/android/miniapp_jni.rs +++ b/src-tauri/src/android/miniapp_jni.rs @@ -90,52 +90,27 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppManager_onMiniAppClosed( log_info!("Mini App closed (JNI callback): {}", miniapp_id); - // Clean up realtime channel and instance state, notify frontend + // Full teardown: destroy Iroh entirely so next session gets a fresh start. + // The old approach (leave_channel) left stale gossip state that broke session 2. if let Some(app) = TAURI_APP.get() { let app = app.clone(); let miniapp_id_owned = miniapp_id.clone(); tauri::async_runtime::spawn(async move { let state = app.state::(); - // Remove the realtime channel state (marks us as not playing) + // Grab channel info before teardown (for peer-left signal + status update) let channel_state = state.remove_realtime_channel(&miniapp_id_owned).await; if let Some(channel) = channel_state { let topic_encoded = crate::miniapps::realtime::encode_topic_id(&channel.topic); - // Get current peer count and clear stale event target - let peer_count = if let Ok(iroh) = state.realtime.get_or_init().await { - iroh.clear_event_target(&channel.topic).await; - let count = iroh.get_peer_count(&channel.topic).await; - - // Leave the Iroh gossip channel (with timeout to avoid hanging) - match tokio::time::timeout( - tokio::time::Duration::from_secs(5), - iroh.leave_channel(channel.topic, &miniapp_id_owned), - ).await { - Ok(Ok(())) => { - log_info!("[WEBXDC] Left Iroh channel on Mini App close: {}", miniapp_id_owned); - } - Ok(Err(e)) => { - log_warn!("[WEBXDC] Failed to leave Iroh channel on close: {}", e); - } - Err(_) => { - log_warn!("[WEBXDC] Timed out leaving Iroh channel on close: {}", miniapp_id_owned); - } - } - - count - } else { - 0 - }; - // Remove ourselves from session peers if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { let my_npub = my_pk.to_bech32().unwrap(); state.remove_session_peer(&channel.topic, &my_npub).await; } - // Emit status update — session_peers is the single source of truth + // Emit status update let session_peers = state.get_session_peers(&channel.topic).await; let session_count = session_peers.len(); if let Some(main_window) = app.get_webview_window("main") { @@ -146,9 +121,9 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppManager_onMiniAppClosed( "is_active": false, "has_pending_peers": session_count > 0, })); - log_info!("[WEBXDC] Emitted miniapp_realtime_status: active=false, peer_count={} for topic {}", session_count, topic_encoded); } - // Send peer-left signal so other clients update their online indicators + + // Send peer-left signal if let Some(instance) = state.get_instance(&miniapp_id_owned).await { let chat_id = instance.chat_id.clone(); let topic_for_left = topic_encoded.clone(); @@ -160,8 +135,21 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppManager_onMiniAppClosed( } } + // NUCLEAR OPTION: Shut down the entire Iroh instance. + // This closes all QUIC connections, leaves all gossip topics, + // and stops the gossip actor. Next miniapp_open creates a fresh one. + match tokio::time::timeout( + tokio::time::Duration::from_secs(5), + state.realtime.shutdown_iroh(), + ).await { + Ok(()) => log_info!("[WEBXDC] Iroh fully shut down on Mini App close"), + Err(_) => log_warn!("[WEBXDC] Iroh shutdown timed out (5s) — forcing drop"), + } + // Remove the instance state.remove_instance(&miniapp_id_owned).await; + + log_info!("[WEBXDC] Mini App cleanup complete: {}", miniapp_id_owned); }); } } diff --git a/src-tauri/src/miniapps/commands.rs b/src-tauri/src/miniapps/commands.rs index 76b1f08e..38d2d41c 100644 --- a/src-tauri/src/miniapps/commands.rs +++ b/src-tauri/src/miniapps/commands.rs @@ -726,17 +726,12 @@ pub async fn miniapp_open( return Ok(()); } else { // Overlay was closed but state was never cleaned up. - // Do full teardown: realtime channel, session peers, instance. + // Full Iroh shutdown — next preconnect creates a fresh instance. log_warn!("Instance exists but overlay closed, full cleanup: {}", existing_label); let channel_state = state.remove_realtime_channel(&existing_label).await; if let Some(channel) = channel_state { let topic_encoded = super::realtime::encode_topic_id(&channel.topic); - if let Ok(iroh) = state.realtime.get_or_init().await { - if let Err(e) = iroh.leave_channel(channel.topic, &existing_label).await { - log_warn!("[WEBXDC] Stale cleanup: leave_channel failed: {}", e); - } - } if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { if let Ok(my_npub) = my_pk.to_bech32() { state.remove_session_peer(&channel.topic, &my_npub).await; @@ -747,7 +742,8 @@ pub async fn miniapp_open( crate::commands::realtime::send_webxdc_peer_left(chat_id_clone, topic_encoded).await; }); } - state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&existing_label); + // Destroy Iroh completely — guarantees clean state for session 2 + state.realtime.shutdown_iroh().await; state.remove_instance(&existing_label).await; } } @@ -1206,19 +1202,11 @@ pub async fn miniapp_close( } } - // Full teardown: remove channel state, leave gossip, clean up - // (Desktop does this in WindowEvent::Destroyed, but Android has no - // Tauri window, so we must do it here explicitly) + // Full teardown: remove channel state, shut down Iroh entirely on Android let channel_state = state.remove_realtime_channel(&label).await; if let Some(channel) = channel_state { let topic_encoded = super::realtime::encode_topic_id(&channel.topic); - if let Ok(iroh) = state.realtime.get_or_init().await { - if let Err(e) = iroh.leave_channel(channel.topic, &label).await { - log_warn!("[WEBXDC] Failed to leave channel on close: {}", e); - } - } - // Remove ourselves from session peers if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() { if let Ok(my_npub) = my_pk.to_bech32() { @@ -1233,8 +1221,12 @@ pub async fn miniapp_close( }); } - // Clean up WS sender - state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&label); + // On Android: full Iroh shutdown so next session gets a clean slate. + // On desktop: WindowEvent::Destroyed handles leave_channel. + #[cfg(target_os = "android")] + { + state.realtime.shutdown_iroh().await; + } state.remove_instance(&label).await; } diff --git a/src-tauri/src/miniapps/realtime.rs b/src-tauri/src/miniapps/realtime.rs index 8d847e71..cc48f971 100644 --- a/src-tauri/src/miniapps/realtime.rs +++ b/src-tauri/src/miniapps/realtime.rs @@ -231,6 +231,35 @@ impl IrohState { }) } + /// Shut down Iroh completely: leave all topics, close endpoint. + /// After this, the instance should be dropped and a fresh one created. + pub async fn shutdown(&self) { + log_info!("[WEBXDC] Shutting down Iroh: leaving all topics..."); + + // 1. Clear all send handles (drops GossipSender clones) + self.send_handles.write().unwrap_or_else(|e| e.into_inner()).clear(); + + // 2. Abort all subscribe loops and drop all channel state + let mut channels = self.channels.write().await; + for (topic, channel) in channels.drain() { + drop(channel.sender); + channel.subscribe_loop.abort(); + let _ = channel.subscribe_loop.await; + log_info!("[WEBXDC] Cleaned up topic {:?}", topic); + } + drop(channels); + + // 3. Shut down gossip actor (sends Disconnect to peers, stops actor loop) + if let Err(e) = self.gossip.shutdown().await { + log_warn!("[WEBXDC] Gossip shutdown error: {e}"); + } + + // 4. Close QUIC endpoint (stops accept loop) + self.endpoint.close().await; + + log_info!("[WEBXDC] Iroh shutdown complete"); + } + /// Notify the endpoint that the network has changed pub async fn network_change(&self) { self.endpoint.network_change().await @@ -805,11 +834,13 @@ async fn run_subscribe_loop( /// Global Iroh state manager /// -/// Uses `OnceCell` for lock-free reads after initialization — -/// `get_or_init()` is a single atomic load on the hot path. +/// Manages the Iroh realtime state lifecycle. +/// Uses `RwLock>>` so the Iroh instance can be fully +/// destroyed on mini app close and recreated fresh on next open. pub struct RealtimeManager { - /// Iroh state (initialized once, then read-only via atomic load) - iroh: tokio::sync::OnceCell, + /// Iroh state — None when not initialized or after shutdown. + /// Arc allows callers to hold a reference while shutdown proceeds. + iroh: tokio::sync::RwLock>>, /// Custom relay URL (if any) relay_url: Option, /// WebSocket server info (port + token), set once after WS server starts @@ -825,7 +856,7 @@ pub struct RealtimeManager { impl RealtimeManager { pub fn new(relay_url: Option) -> Self { Self { - iroh: tokio::sync::OnceCell::new(), + iroh: tokio::sync::RwLock::new(None), relay_url, ws_info: std::sync::OnceLock::new(), send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())), @@ -834,20 +865,54 @@ impl RealtimeManager { } /// Get or initialize the Iroh state. - /// After first call, this is a single atomic load (~5ns). + /// Returns an Arc clone — cheap, and allows callers to hold a reference + /// while shutdown can proceed independently. /// Also starts the realtime WebSocket server on first init. - pub async fn get_or_init(&self) -> Result<&IrohState> { + pub async fn get_or_init(&self) -> Result> { + // Fast path: read lock, check if already initialized + { + let guard = self.iroh.read().await; + if let Some(ref iroh) = *guard { + self.ensure_ws_started(); + return Ok(Arc::clone(iroh)); + } + } + + // Slow path: write lock, double-check, initialize + let mut guard = self.iroh.write().await; + if let Some(ref iroh) = *guard { + self.ensure_ws_started(); + return Ok(Arc::clone(iroh)); + } + let sh = self.send_handles.clone(); - let iroh = self.iroh - .get_or_try_init(|| IrohState::new(self.relay_url.clone(), sh)) - .await?; + let iroh = Arc::new(IrohState::new(self.relay_url.clone(), sh).await?); + *guard = Some(Arc::clone(&iroh)); + drop(guard); - // Start WS server if not already running self.ensure_ws_started(); - Ok(iroh) } + /// Fully shut down Iroh: leave all topics, close endpoint, clear state. + /// Next `get_or_init()` will create a completely fresh instance. + pub async fn shutdown_iroh(&self) { + let iroh = { + let mut guard = self.iroh.write().await; + guard.take() + }; + + // Clear send handles (they hold GossipSender clones from the old instance) + self.send_handles.write().unwrap_or_else(|e| e.into_inner()).clear(); + + // Clear WS senders + self.ws_senders.write().unwrap_or_else(|e| e.into_inner()).clear(); + + if let Some(iroh) = iroh { + iroh.shutdown().await; + } + } + /// Start the WS server if not already running. /// Uses std::net::TcpListener for sync bind (no async runtime needed), /// then spawns the accept loop on tauri::async_runtime so it survives @@ -909,11 +974,9 @@ impl RealtimeManager { }) } - /// Shutdown Iroh if initialized + /// Shutdown Iroh if initialized (delegates to shutdown_iroh) pub async fn shutdown(&self) -> Result<()> { - if let Some(iroh) = self.iroh.get() { - iroh.endpoint.close().await; - } + self.shutdown_iroh().await; Ok(()) } } From 151bc884f0d38c92b03e8b438ae4d9bb87fa84a3 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Sat, 21 Mar 2026 10:43:03 +0000 Subject: [PATCH 009/417] Fix preview image crash when og_image URL fails to load Remove the broken img element on error instead of leaving a blank space. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.js b/src/main.js index c031132f..68c1c197 100644 --- a/src/main.js +++ b/src/main.js @@ -8568,6 +8568,7 @@ function renderMessage(msg, sender, editID = '', contextElement = null) { const imgPreview = document.createElement('img'); imgPreview.classList.add('msg-preview-img'); imgPreview.src = msg.preview_metadata.og_image; + imgPreview.onerror = () => imgPreview.remove(); // Auto-scroll the chat to correct against container resizes imgPreview.addEventListener('load', () => { if (proceduralScrollState.isLoadingOlderMessages) { From 3b05d6307afcb3138278a9bd25a3c0293be032af Mon Sep 17 00:00:00 2001 From: JSKitty Date: Sat, 21 Mar 2026 16:10:18 +0000 Subject: [PATCH 010/417] feat: unified logging system with UTC timestamps, error toasts, and persistent log file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename crash.log → vector.log, get_crash_log → get_logs, "Copy Crash Logs" → "Copy Logs" - log_error! now: prints to stderr with UTC timestamp, appends to vector.log (1000-line cap, trims to 900 when exceeded), and emits a friendly toast to the frontend - Panic hook appends to the same vector.log with timestamped backtrace (was overwriting) - log_warn! adds UTC timestamps to stderr output - Frontend show_toast listener displays "Something went wrong — copy logs in Settings for details" on any backend error (no raw error messages exposed to users) - Logs pre-fetched on init, refreshed on settings open, clipboard copy preserves user gesture for WebKit compatibility - Move "Copy Logs" above Logout in Settings (Logout stays at bottom) - OG image preview: onerror removes broken image instead of showing placeholder Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/capabilities/default.json | 2 +- .../permissions/autogenerated/get_logs.toml | 11 ++++++ src-tauri/src/commands/system.rs | 12 +++--- src-tauri/src/lib.rs | 17 ++++++-- src-tauri/src/macros.rs | 39 +++++++++++++++++-- src/index.html | 18 ++++----- src/js/settings.js | 14 +++---- src/main.js | 14 +++++-- 8 files changed, 93 insertions(+), 34 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/get_logs.toml diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1386e7d6..ab24008e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -234,6 +234,6 @@ "allow-audio-set-volume", "allow-send-recording", "allow-get-audio-metadata", - "allow-get-crash-log" + "allow-get-logs" ] } \ No newline at end of file diff --git a/src-tauri/permissions/autogenerated/get_logs.toml b/src-tauri/permissions/autogenerated/get_logs.toml new file mode 100644 index 00000000..ca410088 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_logs.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-logs" +description = "Enables the get_logs command without any pre-configured scope." +commands.allow = ["get_logs"] + +[[permission]] +identifier = "deny-get-logs" +description = "Denies the get_logs command without any pre-configured scope." +commands.deny = ["get_logs"] diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 522c213e..2576b8d2 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -569,15 +569,15 @@ fn get_total_memory() -> u64 { 0 } -/// Read the crash log file (written by the panic hook in lib.rs). -/// Returns the log contents, or an empty string if no crash log exists. +/// Read the log file (errors + panics, written by log_error! and panic hook). +/// Returns the log contents, or an empty string if no log file exists. #[tauri::command] -pub async fn get_crash_log(handle: AppHandle) -> String { - let crash_path = match handle.path().app_data_dir() { - Ok(dir) => dir.join("crash.log"), +pub async fn get_logs(handle: AppHandle) -> String { + let log_path = match handle.path().app_data_dir() { + Ok(dir) => dir.join("vector.log"), Err(_) => return String::new(), }; - std::fs::read_to_string(crash_path).unwrap_or_default() + std::fs::read_to_string(log_path).unwrap_or_default() } // Handler list for this module (for reference): diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 74aaadc8..8d3755a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,11 +105,19 @@ pub fn run() { // Without this, panics in spawned tasks vanish silently. std::panic::set_hook(Box::new(|info| { let backtrace = std::backtrace::Backtrace::force_capture(); - let msg = format!("PANIC: {info}\n\nBacktrace:\n{backtrace}"); + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let msg = format!("[PANIC {:02}:{:02}:{:02}Z] {info}\n\nBacktrace:\n{backtrace}\n", + (secs / 3600) % 24, (secs / 60) % 60, secs % 60); eprintln!("{msg}"); - // Write crash log to the Tauri app data dir (set during setup via set_app_data_dir) + // Append to log file (shared with log_error!) if let Ok(data_dir) = account_manager::get_app_data_dir() { - let _ = std::fs::write(data_dir.join("crash.log"), &msg); + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(data_dir.join("vector.log")) { + let _ = write!(f, "{}\n", &msg); + } } })); @@ -282,6 +290,7 @@ pub fn run() { tauri::async_runtime::spawn(async { profile_sync::start_profile_sync_processor().await; }); + // Setup deep link listener for macOS/iOS/Android // On these platforms, deep links are received as events rather than CLI args @@ -543,7 +552,7 @@ pub fn run() { commands::sync::sync_all_profiles, // System commands (commands/system.rs) commands::system::run_maintenance, - commands::system::get_crash_log, + commands::system::get_logs, // Encryption toggle commands (commands/encryption.rs) commands::encryption::get_encryption_status, commands::encryption::get_encryption_and_key, diff --git a/src-tauri/src/macros.rs b/src-tauri/src/macros.rs index cd9b3689..bc743e15 100644 --- a/src-tauri/src/macros.rs +++ b/src-tauri/src/macros.rs @@ -1,10 +1,12 @@ /// Log macros that replace the `log` crate. /// /// `log_info!`, `log_debug!`, `log_trace!` compile to no-ops in release builds. -/// `log_warn!` and `log_error!` always print (important for diagnostics). +/// `log_warn!` and `log_error!` always print with UTC timestamps (important for diagnostics). +/// `log_error!` also emits a toast to the frontend so users know something went wrong. #[allow(unused_macros)] + macro_rules! log_info { ($($arg:tt)*) => {{ #[cfg(debug_assertions)] @@ -28,12 +30,43 @@ macro_rules! log_trace { macro_rules! log_warn { ($($arg:tt)*) => {{ - eprintln!("[WARN] {}", format_args!($($arg)*)); + let _secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + eprintln!("[WARN {:02}:{:02}:{:02}Z] {}", (_secs / 3600) % 24, (_secs / 60) % 60, _secs % 60, format_args!($($arg)*)); }}; } macro_rules! log_error { ($($arg:tt)*) => {{ - eprintln!("[ERROR] {}", format_args!($($arg)*)); + let _secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let msg = format!($($arg)*); + let line = format!("[ERROR {:02}:{:02}:{:02}Z] {}", (_secs / 3600) % 24, (_secs / 60) % 60, _secs % 60, &msg); + eprintln!("{}", &line); + // Append to vector.log (capped at 1000 lines) + if let Ok(data_dir) = $crate::account_manager::get_app_data_dir() { + let log_path = data_dir.join("vector.log"); + // Trim to 1000 lines if over limit + if let Ok(existing) = std::fs::read_to_string(&log_path) { + let lines: Vec<&str> = existing.lines().collect(); + if lines.len() > 1000 { + let trimmed = lines[lines.len() - 900..].join("\n"); + let _ = std::fs::write(&log_path, trimmed); + } + } + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { + let _ = writeln!(f, "{}", &line); + } + } + // Notify user that an error occurred (details are in Settings > Copy Logs) + if let Some(handle) = $crate::TAURI_APP.get() { + use tauri::Emitter; + let _ = handle.emit("show_toast", "Something went wrong — copy logs in Settings for details"); + } }}; } diff --git a/src/index.html b/src/index.html index 4e083732..f04f3ad9 100644 --- a/src/index.html +++ b/src/index.html @@ -1137,22 +1137,22 @@

Dangerzone

- - Logout + + Copy Logs
-
- - Copy Crash Logs + + Logout
-
diff --git a/src/js/settings.js b/src/js/settings.js index fa8799cb..34be9e3a 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -1783,18 +1783,18 @@ async function initSettings() { if (success) initStorageSection(); }); - // Pre-fetch crash log so clipboard.writeText runs synchronously on click - let cachedCrashLog = ''; - invoke('get_crash_log').then((log) => { cachedCrashLog = log || ''; }); + // Pre-fetch logs so clipboard.writeText runs synchronously on click (user gesture required) + window._cachedLogs = ''; + invoke('get_logs').then((log) => { window._cachedLogs = log || ''; }); const copyCrashLogBtn = document.getElementById('copy-crash-log-btn'); copyCrashLogBtn.addEventListener('click', () => { - if (!cachedCrashLog) { + if (!window._cachedLogs) { showToast('No logs to copy!'); return; } - const lines = cachedCrashLog.split('\n').length; - navigator.clipboard.writeText(cachedCrashLog).then(() => { - showToast('Copied ' + lines + ' log lines to clipboard'); + const lines = window._cachedLogs.split('\n').filter(l => l.trim()).length; + navigator.clipboard.writeText(window._cachedLogs).then(() => { + showToast('Copied ' + lines + ' log entries to clipboard'); }); }); diff --git a/src/main.js b/src/main.js index 68c1c197..5c6c6c5f 100644 --- a/src/main.js +++ b/src/main.js @@ -4449,6 +4449,11 @@ async function setupRustListeners() { } } + // Listen for backend error toasts + _on('show_toast', (evt) => { + showToast(evt.payload || 'An error occurred'); + }); + // Listen for Attachment Download Progress events _on('attachment_download_progress', async (evt) => { if (!strOpenChat) return; @@ -10379,8 +10384,9 @@ function openSettings() { // Update the Storage Breakdown initStorageSection(); - // Refresh blocked users list + // Refresh blocked users list and logs cache loadBlockedUsersList(); + invoke('get_logs').then((log) => { window._cachedLogs = log || ''; }); // Check primary device status when settings are opened checkPrimaryDeviceStatus(); @@ -11489,15 +11495,15 @@ domChatMessageInput.oninput = async () => { }; } - // Info button for Copy Crash Logs + // Info button for Copy Logs const domCrashLogInfo = document.getElementById('crash-log-info'); if (domCrashLogInfo) { domCrashLogInfo.onclick = (e) => { e.preventDefault(); e.stopPropagation(); popupConfirm( - 'Crash Logs', - 'If Vector crashed unexpectedly, this copies the technical details to your clipboard.

Share it with developers when reporting bugs to help diagnose the issue.', + 'Logs', + 'Copies error logs and crash details to your clipboard.

Share with developers when reporting bugs to help diagnose issues.', true ); }; From ec60c8bb4160e38feebb69473d9d8bacd5f7f0cd Mon Sep 17 00:00:00 2001 From: JSKitty Date: Sat, 21 Mar 2026 20:34:49 +0000 Subject: [PATCH 011/417] security: zeroize sensitive key material from memory after use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `zeroize` crate (transitive via chacha20poly1305, zero binary increase) to explicitly overwrite sensitive data in memory before deallocation, preventing extraction via memory dumps, forensic tools, or malware with process read access. What's zeroized: - PIN/password: cleared immediately after Argon2 key derivation (~100ms lifetime) - Encryption key copies: zeroed after every encrypt/decrypt call + all error paths - Plaintext input to encrypt: zeroed right after ChaCha20 reads it (nsec, seed, messages) - Mnemonic seed phrase: zeroed after persisting to DB (was session-lifetime, now seconds) Changed MNEMONIC_SEED from OnceLock to Mutex> to enable clearing - Logout: ENCRYPTION_KEY, PENDING_NSEC, and MNEMONIC_SEED all explicitly zeroed before process restart - Key generation params: raw key bytes zeroed after hex conversion Without zeroize, Rust's String/Vec drop deallocates but doesn't overwrite — sensitive bytes persist in heap until the allocator reuses that page (seconds to hours). With zeroize, the compiler cannot optimize away the volatile memory writes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/account.rs | 80 ++++++++++++++++++++++++------- src-tauri/src/crypto.rs | 39 ++++++++++----- src-tauri/src/state/globals.rs | 5 +- 5 files changed, 95 insertions(+), 31 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8f8b18b..383837c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -9253,6 +9253,7 @@ dependencies = [ "toml 0.8.23", "url", "whisper-rs", + "zeroize", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index efc4b28f..2d5e579b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,7 @@ rayon = "1.11.0" # Transitive Dependencies (re-used from frameworks we already utilise, like Tauri) memchr = "2" +zeroize = "1.8" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } # Mini Apps (WebXDC) support diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 92db7765..8acb02b7 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -194,6 +194,38 @@ pub async fn logout(handle: AppHandle) { } } + // Zeroize the encryption key before restart + { + use zeroize::Zeroize; + let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); + if let Some(ref mut key) = *guard { + key.zeroize(); + } + *guard = None; + } + + // Zeroize the pending nsec if still held + { + use zeroize::Zeroize; + if let Ok(mut guard) = crate::PENDING_NSEC.lock() { + if let Some(ref mut nsec) = *guard { + nsec.zeroize(); + } + *guard = None; + } + } + + // Zeroize the mnemonic seed if still held + { + use zeroize::Zeroize; + if let Ok(mut guard) = crate::MNEMONIC_SEED.lock() { + if let Some(ref mut seed) = *guard { + seed.zeroize(); + } + *guard = None; + } + } + // Restart the Core process handle.restart(); } @@ -235,7 +267,7 @@ pub async fn create_account() -> Result { STATE.lock().await.insert_or_replace_profile(&npub, profile); // Save the seed in memory, ready for post-pin-setup encryption - let _ = MNEMONIC_SEED.set(mnemonic_string); + *MNEMONIC_SEED.lock().unwrap() = Some(mnemonic_string); // Store npub temporarily - database will be created when set_pkey is called (after user sets PIN) // This prevents creating "dead accounts" if user quits before setting a PIN @@ -259,8 +291,9 @@ pub async fn export_keys() -> Result { }; // Try to get seed phrase from memory first, then from database - let seed_phrase = if let Some(seed) = MNEMONIC_SEED.get() { - Some(seed.clone()) + let seed_from_mem = MNEMONIC_SEED.lock().unwrap().clone(); + let seed_phrase = if seed_from_mem.is_some() { + seed_from_mem } else { match db::get_seed().await { Ok(Some(seed)) => Some(seed), @@ -288,12 +321,9 @@ pub async fn encrypt(input: String, password: Option) -> String { let res = crypto::internal_encrypt(input, password).await; // If we have one; save the in-memory seedphrase in an encrypted at-rest format - match MNEMONIC_SEED.get() { - Some(seed) => { - // Save the seed phrase to the database - let _ = db::set_seed(seed.to_string()).await; - } - _ => () + let seed_copy = MNEMONIC_SEED.lock().unwrap().clone(); + if let Some(seed) = seed_copy { + let _ = db::set_seed(seed).await; } // Check if we have a pending invite acceptance to broadcast @@ -478,7 +508,7 @@ pub async fn setup_encryption( let nsec = PENDING_NSEC.lock().unwrap().take() .ok_or("No pending key — call create_account or login first")?; - // Encrypt the key with the user's password + // Encrypt the key with the user's password (internal_encrypt zeroizes the plaintext) let encrypted = crypto::internal_encrypt(nsec, Some(password)).await; // Store via set_pkey (handles pending account DB creation + MLS bootstrap) @@ -488,9 +518,18 @@ pub async fn setup_encryption( db::set_sql_setting("security_type".to_string(), security_type)?; crate::state::set_encryption_enabled(true); - // Save seed phrase if available - if let Some(seed) = MNEMONIC_SEED.get() { - let _ = db::set_seed(seed.to_string()).await; + // Save seed phrase if available, then zeroize from memory + { + use zeroize::Zeroize; + let seed_copy = MNEMONIC_SEED.lock().unwrap().clone(); + if let Some(seed) = seed_copy { + let _ = db::set_seed(seed).await; + } + let mut guard = MNEMONIC_SEED.lock().unwrap(); + if let Some(ref mut seed) = *guard { + seed.zeroize(); + } + *guard = None; } // Broadcast pending invite acceptance @@ -536,9 +575,18 @@ pub async fn skip_encryption(handle: AppHandle) -> Result<(), Str db::set_sql_setting("encryption_enabled".to_string(), "false".to_string())?; crate::state::set_encryption_enabled(false); - // Save seed phrase if available (stored plaintext since encryption is disabled) - if let Some(seed) = MNEMONIC_SEED.get() { - let _ = db::set_seed(seed.to_string()).await; + // Save seed phrase if available, then zeroize from memory + { + use zeroize::Zeroize; + let seed_copy = MNEMONIC_SEED.lock().unwrap().clone(); + if let Some(seed) = seed_copy { + let _ = db::set_seed(seed).await; + } + let mut guard = MNEMONIC_SEED.lock().unwrap(); + if let Some(ref mut seed) = *guard { + seed.zeroize(); + } + *guard = None; } Ok(()) diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs index 6862a77f..ca53af04 100644 --- a/src-tauri/src/crypto.rs +++ b/src-tauri/src/crypto.rs @@ -9,6 +9,7 @@ use chacha20poly1305::{ aead::Aead, ChaCha20Poly1305, Nonce }; +use zeroize::Zeroize; /// Represents encryption parameters #[derive(Debug)] @@ -20,16 +21,18 @@ pub struct EncryptionParams { /// Generates random encryption parameters (key and nonce) pub fn generate_encryption_params() -> EncryptionParams { let mut rng = rand::thread_rng(); - + // Generate 32 byte key (for AES-256) - let key: [u8; 32] = rng.gen(); + let mut key: [u8; 32] = rng.gen(); // Generate 16 byte nonce (to match 0xChat) let nonce: [u8; 16] = rng.gen(); - - EncryptionParams { + + let params = EncryptionParams { key: bytes_to_hex_string(&key), nonce: bytes_to_hex_string(&nonce), - } + }; + key.zeroize(); + params } /// Encrypts data using AES-256-GCM with a 16-byte nonce @@ -62,7 +65,7 @@ pub fn encrypt_data(data: &[u8], params: &EncryptionParams) -> Result, S } /// Hash a password using Argon2id -pub async fn hash_pass(password: String) -> [u8; 32] { +pub async fn hash_pass(mut password: String) -> [u8; 32] { // 150000 KiB memory size let memory = 150000; // 10 iterations @@ -80,13 +83,16 @@ pub async fn hash_pass(password: String) -> [u8; 32] { .hash_password_into(password.as_bytes(), salt, &mut key) .unwrap(); + // Zeroize the password from memory + password.zeroize(); + key } /// Internal function for encryption logic using ChaCha20Poly1305 -pub async fn internal_encrypt(input: String, password: Option) -> String { +pub async fn internal_encrypt(mut input: String, password: Option) -> String { // Hash our password with Argon2 and use it as the key - let key: [u8; 32] = if password.is_none() { + let mut key: [u8; 32] = if password.is_none() { // Read the cached key let guard = crate::ENCRYPTION_KEY.read().unwrap(); *guard.as_ref().expect("Encryption key must be set") @@ -105,10 +111,11 @@ pub async fn internal_encrypt(input: String, password: Option) -> String // Create the nonce let nonce: Nonce = nonce_bytes.into(); - // Encrypt the input + // Encrypt the input, then zeroize plaintext let ciphertext = cipher .encrypt(&nonce, input.as_bytes()) .expect("Encryption should not fail"); + input.zeroize(); // Prepend the nonce to our ciphertext let mut buffer = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); @@ -123,6 +130,9 @@ pub async fn internal_encrypt(input: String, password: Option) -> String } } + // Zeroize the local key copy + key.zeroize(); + // Convert the encrypted bytes to a hex string for safe storage/transmission bytes_to_hex_string(&buffer) } @@ -133,7 +143,7 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R let has_password = password.is_some(); // Get the key - either from password or cached - let key: [u8; 32] = if let Some(pass) = password { + let mut key: [u8; 32] = if let Some(pass) = password { // Hash the password hash_pass(pass).await } else { @@ -148,7 +158,7 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R // Convert hex to bytes - use reference to avoid copying the string let encrypted_data = match hex_string_to_bytes(ciphertext.as_str()) { bytes if bytes.len() >= 12 => bytes, - _ => return Err(()) + _ => { key.zeroize(); return Err(()) } }; // Extract nonce and encrypted data - use slices to avoid copying data @@ -157,7 +167,7 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R // Create the cipher instance let cipher = match ChaCha20Poly1305::new_from_slice(&key) { Ok(c) => c, - Err(_) => return Err(()) + Err(_) => { key.zeroize(); return Err(()) } }; // Create the nonce and decrypt @@ -165,7 +175,7 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R let nonce: Nonce = nonce_arr.into(); let plaintext = match cipher.decrypt(&nonce, actual_ciphertext) { Ok(pt) => pt, - Err(_) => return Err(()) + Err(_) => { key.zeroize(); return Err(()) } }; // Cache the key if needed - only set if we came from password path @@ -176,6 +186,9 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R } } + // Zeroize the local key copy + key.zeroize(); + // Convert decrypted bytes to string using unsafe version, because SPEED! // SAFETY: The plaintext bytes are guaranteed to be valid UTF-8, making this safe, because: // 1. They were originally created from a valid UTF-8 string (typically JSON or plaintext) diff --git a/src-tauri/src/state/globals.rs b/src-tauri/src/state/globals.rs index 6fc1a6b3..f70f6477 100644 --- a/src-tauri/src/state/globals.rs +++ b/src-tauri/src/state/globals.rs @@ -132,8 +132,9 @@ pub fn get_blossom_servers() -> Vec { .clone() } -/// Mnemonic seed for wallet/key derivation -pub static MNEMONIC_SEED: OnceLock = OnceLock::new(); +/// Mnemonic seed for wallet/key derivation. +/// Uses Mutex> so it can be zeroized and cleared after persisting to DB. +pub static MNEMONIC_SEED: std::sync::Mutex> = std::sync::Mutex::new(None); /// Temporary nsec storage between create_account/login and setup_encryption/skip_encryption. /// The private key is set here and consumed by encryption setup — it never crosses IPC. From e05114bd13f086d291f51643be52cefa3d881e9f Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 00:45:35 +0000 Subject: [PATCH 012/417] security: memory-hardened key vault with anti-debug and zeroize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace plaintext key storage (OnceLock, RwLock>) with a 128-array vault that XOR-splits keys into 4 shares scattered across indistinguishable decoy arrays. Positions derived from ASLR addresses with zero compile-time constants. - GuardedKey vault: set/get/clear with cross-key protection, collision-free position derivation, decoy writes excluding other active keys' positions - GuardedSigner: NostrSigner that reconstructs keys per-sign (~μs lifetime) - Anti-debug: PT_DENY_ATTACH (macOS), PR_SET_DUMPABLE (Linux/Android), DACL + signature policy (Windows) — release builds only - Zeroize: all login paths, encryption key paths, passwords, seeds, nsec strings cleared via volatile writes immediately after use - Nostr fork (VectorPrivacy/nostr#zeroize-secretkey): SecretKey::Drop uses zeroize instead of compiler-optimizable non_secure_erase - 30-unit test suite validated with 100k stress iterations Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/security/memory-security.md | 102 +++ src-tauri/Cargo.lock | 11 +- src-tauri/Cargo.toml | 5 + src-tauri/patches/iroh | 1 + src-tauri/src/android/background_sync.rs | 10 +- src-tauri/src/commands/account.rs | 63 +- src-tauri/src/commands/encryption.rs | 53 +- src-tauri/src/commands/mls.rs | 2 +- src-tauri/src/crypto.rs | 32 +- src-tauri/src/guarded_key.rs | 889 +++++++++++++++++++++++ src-tauri/src/lib.rs | 81 ++- src-tauri/src/message/sending.rs | 4 +- src-tauri/src/mls/messaging.rs | 2 +- src-tauri/src/profile.rs | 2 +- src-tauri/src/state/globals.rs | 12 +- src-tauri/src/state/mod.rs | 2 +- 16 files changed, 1172 insertions(+), 99 deletions(-) create mode 100644 docs/security/memory-security.md create mode 160000 src-tauri/patches/iroh create mode 100644 src-tauri/src/guarded_key.rs diff --git a/docs/security/memory-security.md b/docs/security/memory-security.md new file mode 100644 index 00000000..9d87419e --- /dev/null +++ b/docs/security/memory-security.md @@ -0,0 +1,102 @@ +# Vector — Memory Security Architecture + +## Overview + +Vector stores private keys using a memory-hardened vault designed to resist automated extraction tools, forensic imaging, and targeted reverse engineering. This document describes the protection layers, threat model analysis, and platform-specific hardening. + +## Vault Architecture + +Private keys are **never stored as contiguous values** in process memory. Instead: + +1. Each key is **XOR-split into 4 random shares** (secret splitting) +2. Each share is stored as individual `usize` values scattered across **128 static arrays** +3. Of the 128 arrays, only a handful contain real key data — the rest are **indistinguishable decoys** filled with identical random values +4. Share positions are derived from **ASLR addresses** that change every launch, with **guaranteed uniqueness** — no two positions can collide (rehash on collision) +5. All derivation uses **zero compile-time constants** — multipliers and iteration counts are computed from ASLR addresses that change every launch + +### Properties + +- **Zero heap allocations** — no pointers, no allocation metadata, no structural fingerprints +- **Zero searchable constants** — no magic numbers in the binary for reverse engineers to find +- **Provably indistinguishable arrays** — all 128 arrays have identical size, content distribution, initialization timing, and modification patterns +- **Snapshot-diff resistant** — during key storage, ALL 128 arrays receive random writes, making real writes invisible +- **Multi-key isolation** — multiple keys (signing key, encryption key) share the same vault arrays but cannot corrupt each other. Decoy writes from one key's set/clear operations exclude positions belonging to other active keys +- **Zero side-channel** — key retrieval performs only atomic reads (no memory writes) +- **Computational hardening** — thousands of mixing iterations per derivation, making brute-force expensive + +### Design Rationale + +**128 arrays** — power-of-2 so modular arithmetic compiles to AND masks (no searchable magic divisor constants). Enough decoys to be provably indistinguishable, small enough to not create an excessively distinctive memory footprint. + +**4,096 entries per array** — matches common crypto table sizes (S-boxes, hash constants), maximizing false positives during memory scanning (thousands of candidates in a typical process). Power-of-2 for the same AND-mask benefit. + +Increasing either dimension does not improve security against the primary attack vector (instance address brute-force), but does increase memory usage and binary fingerprint distinctiveness. Decreasing them reduces false positives. The current values are a deliberate balance of stealth, chaff density, and resource cost (4 MB on 64-bit, 2 MB on 32-bit). + +## Platform Hardening + +In release builds, Vector applies platform-specific protections at startup to prevent external processes from reading its memory or attaching debuggers. + +### macOS +- Debugger attachment and `task_for_pid` blocked via `ptrace(PT_DENY_ATTACH)` + +### Linux / Android +- `/proc/pid/mem` access, `ptrace` attachment, and core dumps blocked via `prctl(PR_SET_DUMPABLE, 0)` + +### Windows +- Debugger detection at startup +- Unsigned DLL injection blocked via process mitigation policy (Microsoft-signed only) +- `ReadProcessMemory` blocked by stripping `PROCESS_VM_READ` from the process DACL + +All protections are release-only — debug builds remain debuggable. + +## Threat Model + +### Protected against + +- **Info-stealers and automated memory scanners** — no known key patterns, signatures, or structural fingerprints exist in memory. Generic scanning tools find nothing actionable. +- **Forensic imaging tools** — without a Vector-specific extraction module, standard forensic suites cannot identify key material among the vault's random data. +- **Memory dump analysis** — a raw memory dump contains 128 arrays of random values. Even with the derivation algorithm (open source), extracting keys requires brute-forcing ASLR-dependent addresses — a computationally expensive search with no shortcuts. +- **Core dump exposure** — on Linux/Android, `PR_SET_DUMPABLE=0` suppresses core dumps, preventing key material from being written to disk on crash. On macOS, `PT_DENY_ATTACH` prevents third-party memory capture. On Windows, the DACL blocks external memory reads. +- **Swap file / hibernation** — if pages are swapped to disk under memory pressure (possible on all platforms since mlock is not used), key shares exist as individual `usize` values indistinguishable from random data. An attacker with a swap file would face the same brute-force as a memory dump. + +### Resistant to (requires significant effort) + +- **Targeted reverse engineering** — an attacker with Vector's source code, the release binary, and a memory snapshot must perform manual binary analysis (hours in tools like Ghidra) to determine the runtime relationship between the vault arrays and key storage. LTO and ASLR make static layout unpredictable across builds. +- **Brute-force with source code** — with the algorithm and a memory dump, the attacker must brute-force the ASLR instance address. Computational hardening (~25 μs/attempt) limits throughput. The search space depends on platform ASLR entropy (minutes to hours, single-core). The vault is the second defensive layer — anti-debug protections prevent obtaining the dump in the first place. + +### Not protected against + +- **Code injection with elevated privileges** — an attacker who can load code into Vector's process can call the key retrieval function directly, bypassing the vault entirely. This requires either root/admin access (to override anti-debug protections), pre-launch environment control (to inject libraries before protections activate), or binary modification (to patch out the protections, invalidating the code signature). None of these can be performed by a same-privilege, post-launch process. +- **Kernel-level access** — a kernel rootkit cannot crack the vault's mathematics (it faces the same brute-force as any memory reader), but it can bypass the vault entirely by modifying Vector's code in memory to intercept keys during the brief plaintext window of signing operations, or by setting hardware breakpoints on vault access. This is an active code-modification attack, not a passive memory-reading attack. +- **Keylogging** — capturing the PIN/password before it reaches the application bypasses all memory protection. + +## Comparison with Other Messaging Applications + +Analysis performed 2026-03-23 against Signal Desktop ([`a8118faf`](https://github.com/signalapp/Signal-Desktop/tree/a8118faf0888682a53dc2cd5c11fcd0cc4a30573)), libsignal ([`a5e76674`](https://github.com/signalapp/libsignal/tree/a5e76674882a89bac1ed3f4a982120652966d21e)), and Telegram Desktop ([`ba4715a3`](https://github.com/telegramdesktop/tdesktop/tree/ba4715a3afc49554aef36a67335c519d05701162)). + +| Protection | Vector | Signal Desktop | Telegram Desktop | +|---|---|---|---| +| Key splitting | 4 XOR shares across 128 arrays | None | None | +| Memory obfuscation | 128 decoy arrays, zero constants | None | None | +| Key zeroization | All paths (passwords, seeds, keys) | Symmetric cipher state only | DH exchange temps only | +| Anti-debug | PT_DENY_ATTACH / PR_SET_DUMPABLE / DACL | None | None | +| Anti-DLL injection | Microsoft-signed only policy (Windows) | None | None | +| Key extraction difficulty | Hours (with source + binary + snapshot) | Instant (grep for 32 bytes) | Instant (follow pointer) | + +**Signal Desktop** stores identity keys in a [plain JavaScript `Map`](https://github.com/signalapp/Signal-Desktop/blob/a8118faf0888682a53dc2cd5c11fcd0cc4a30573/ts/SignalProtocolStore.preload.ts#L249) with no memory protection. At the Rust layer, libsignal's [`PrivateKey` derives `Copy`](https://github.com/signalapp/libsignal/blob/a5e76674882a89bac1ed3f4a982120652966d21e/rust/core/src/curve.rs#L236-L239) with no `Zeroize` or `Drop` — the raw `[u8; 32]` is freely duplicated on the stack without clearing. The `zeroize` crate exists in the workspace but is [not applied to any asymmetric key type](https://github.com/signalapp/libsignal/blob/a5e76674882a89bac1ed3f4a982120652966d21e/rust/core/Cargo.toml). Signal uses Electron's `safeStorage` for the database key at rest, but this is disk encryption — not memory hardening. + +**Telegram Desktop** stores the MTProto auth key as a [plain `std::array`](https://github.com/telegramdesktop/tdesktop/blob/ba4715a3afc49554aef36a67335c519d05701162/Telegram/SourceFiles/mtproto/mtproto_auth_key.h#L16-L63) with no custom destructor. The only [`OPENSSL_cleanse` usage](https://github.com/telegramdesktop/tdesktop/blob/ba4715a3afc49554aef36a67335c519d05701162/Telegram/SourceFiles/mtproto/details/mtproto_dc_key_creator.cpp#L368-L375) is in `DcKeyCreator::Attempt::~Attempt()` — cleaning DH exchange temporaries after the key has already been [copied into an unprotected `AuthKey`](https://github.com/telegramdesktop/tdesktop/blob/ba4715a3afc49554aef36a67335c519d05701162/Telegram/SourceFiles/mtproto/details/mtproto_dc_key_creator.cpp#L829-L839). No `mlock`, `mprotect`, anti-debug, or memory-hardening of any kind exists. + +### Known limitations + +- **Transient key exposure** — during signing or encryption operations, the reconstructed 32-byte key exists on the stack for microseconds. This window is minimized by the patched nostr SDK (which uses `zeroize` volatile writes on `SecretKey::Drop`), but is not zero. +- **Binary size fingerprint** — the 128 arrays create a ~4 MB contiguous block in BSS. This is detectable as "large high-entropy static data" in a memory scan. The block itself doesn't reveal the key, but it identifies where the vault data resides. Extracting the key still requires the full brute-force. + +## Additional Protections + +Beyond the vault, Vector applies defense-in-depth: + +- **Zeroize** — passwords, mnemonic seeds, nsec strings, and temporary key copies are overwritten with zeros immediately after use via the `zeroize` crate (volatile writes that resist compiler optimization). +- **Per-sign key reconstruction** — the Nostr signing interface reconstructs the key from the vault for each operation. The key exists in plaintext only for microseconds during signing, then is dropped and zeroized. +- **No permanent key copies** — the `GuardedSigner` reads from the vault on demand, replacing the previous approach of holding a permanent `Keys` copy in memory. +- **No reusable cracker** — ASLR changes the instance address every launch, and LTO changes binary offsets every build. A cracker crafted for one version breaks on the next update, ruling out commodity tooling and redistributable extraction scripts. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 383837c4..51217ff6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -502,8 +502,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", - "rand_core 0.6.4", + "rand 0.7.3", + "rand_core 0.5.1", "serde", "unicode-normalization", ] @@ -4721,9 +4721,8 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +version = "0.44.0" +source = "git+https://github.com/VectorPrivacy/nostr.git?branch=zeroize-secretkey#0f9f3a7ac42a0ac699b48458e87b4130c00cb54e" dependencies = [ "base64 0.22.1", "bech32", @@ -4741,6 +4740,7 @@ dependencies = [ "serde_json", "unicode-normalization", "url", + "zeroize", ] [[package]] @@ -9211,6 +9211,7 @@ dependencies = [ "iroh", "iroh-gossip", "jni", + "libc", "lofty", "mdk-core", "mdk-sqlite-storage", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d5e579b..ac800518 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ rayon = "1.11.0" # Transitive Dependencies (re-used from frameworks we already utilise, like Tauri) memchr = "2" zeroize = "1.8" +libc = "0.2" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } # Mini Apps (WebXDC) support @@ -132,3 +133,7 @@ whisper-rs-sys = { git = "https://github.com/VectorPrivacy/whisper-rs.git", bran # pkarr uses reqwest with "rustls" feature which forces aws-lc-rs (breaks Android 11). # Patched to use "rustls-no-provider" so our ring install_default() is used instead. pkarr = { path = "patches/pkarr" } +# nostr SDK's SecretKey Drop uses non_secure_erase which the compiler can optimize away. +# Patched to use zeroize (volatile writes) so secret key bytes are guaranteed cleared from the stack. +# Fork: https://github.com/VectorPrivacy/nostr/tree/zeroize-secretkey +nostr = { git = "https://github.com/VectorPrivacy/nostr.git", branch = "zeroize-secretkey" } diff --git a/src-tauri/patches/iroh b/src-tauri/patches/iroh new file mode 160000 index 00000000..3d6e26b1 --- /dev/null +++ b/src-tauri/patches/iroh @@ -0,0 +1 @@ +Subproject commit 3d6e26b1182ecf63ce273793d44b3908117211e1 diff --git a/src-tauri/src/android/background_sync.rs b/src-tauri/src/android/background_sync.rs index 74bce59f..5d58cc07 100644 --- a/src-tauri/src/android/background_sync.rs +++ b/src-tauri/src/android/background_sync.rs @@ -16,7 +16,7 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use crate::{NOSTR_CLIENT, MY_KEYS, MY_PUBLIC_KEY}; +use crate::{NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY}; use crate::commands::relays::DEFAULT_RELAYS; use crate::services::event_handler::handle_event_with_context; @@ -265,7 +265,8 @@ fn run_standalone_sync_loop(data_dir: &str) { let _ = NOSTR_CLIENT.set(client.clone()); let _ = MY_PUBLIC_KEY.set(my_public_key); if let Some(keys) = keys { - let _ = MY_KEYS.set(keys); + MY_SECRET_KEY.store_from_keys(&keys); + drop(keys); } } @@ -510,8 +511,11 @@ async fn bootstrap_client(data_dir: &str) -> Result<(Client, PublicKey, bool, Op logcat(&format!("Bootstrapped client for {}...", &my_public_key.to_bech32().unwrap_or_default()[..20.min(my_public_key.to_bech32().unwrap_or_default().len())])); + let public_key_for_signer = keys.public_key(); + MY_SECRET_KEY.store_from_keys(&keys); + let client = Client::builder() - .signer(keys.clone()) + .signer(crate::guarded_key::GuardedSigner::new(public_key_for_signer)) .build(); for relay_url in DEFAULT_RELAYS { diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 8acb02b7..def94426 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -9,8 +9,9 @@ use nostr_sdk::prelude::*; use tauri::{AppHandle, Emitter, Manager, Runtime}; +use zeroize::Zeroize; -use crate::{STATE, TAURI_APP, NOSTR_CLIENT, MY_KEYS, MY_PUBLIC_KEY, MNEMONIC_SEED, PENDING_NSEC, PENDING_INVITE, active_trusted_relays}; +use crate::{STATE, TAURI_APP, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, MNEMONIC_SEED, PENDING_NSEC, PENDING_INVITE, active_trusted_relays}; use crate::{Profile, account_manager, db, crypto, commands}; // ============================================================================ @@ -83,7 +84,7 @@ pub async fn debug_hot_reload_sync() -> Result { /// The private key is stored in PENDING_NSEC for setup_encryption/skip_encryption /// to consume — it is never returned over IPC. #[tauri::command] -pub async fn login(import_key: String) -> Result { +pub async fn login(mut import_key: String) -> Result { let keys: Keys; // If we're already logged in (i.e: Developer Mode with frontend hot-loading), just return the existing keys. @@ -106,9 +107,14 @@ pub async fn login(import_key: String) -> Result { Ok(parsed) => keys = parsed, Err(_) => return Err(String::from("Invalid nsec")), }; + // Zeroize the nsec string — Keys struct has the parsed data + import_key.zeroize(); } else { // Otherwise, we'll try importing it as a mnemonic seed phrase (BIP-39) - match Keys::from_mnemonic(import_key, Some(String::new())) { + // from_mnemonic takes ownership, so clone for zeroize + let mnemonic_copy = import_key.clone(); + import_key.zeroize(); + match Keys::from_mnemonic(mnemonic_copy, Some(String::new())) { Ok(parsed) => keys = parsed, Err(_) => return Err(String::from("Invalid Seed Phrase")), }; @@ -120,19 +126,21 @@ pub async fn login(import_key: String) -> Result { *pending = Some(keys.secret_key().to_bech32().unwrap()); } - // Initialise the Nostr client - let _ = MY_KEYS.set(keys.clone()); - let _ = MY_PUBLIC_KEY.set(keys.public_key); + // Store secret key in the guarded vault, then construct the client with GuardedSigner + let public_key = keys.public_key; + MY_SECRET_KEY.store_from_keys(&keys); + let _ = MY_PUBLIC_KEY.set(public_key); + drop(keys); // Drop the Keys struct (secp256k1 Drop zeroizes) let client = Client::builder() - .signer(keys.clone()) + .signer(crate::guarded_key::GuardedSigner::new(public_key)) .opts(ClientOptions::new()) .monitor(Monitor::new(1024)) .build(); NOSTR_CLIENT.set(client).unwrap(); // Add our profile (at least, the npub of it) to our state - let npub = keys.public_key.to_bech32().unwrap(); + let npub = public_key.to_bech32().unwrap(); let mut profile = Profile::new(); profile.flags.set_mine(true); STATE.lock().await.insert_or_replace_profile(&npub, profile); @@ -194,15 +202,9 @@ pub async fn logout(handle: AppHandle) { } } - // Zeroize the encryption key before restart - { - use zeroize::Zeroize; - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - if let Some(ref mut key) = *guard { - key.zeroize(); - } - *guard = None; - } + // Clear all guarded key vaults (zeroizes all shares + decoys) + crate::ENCRYPTION_KEY.clear(); + crate::MY_SECRET_KEY.clear(); // Zeroize the pending nsec if still held { @@ -249,19 +251,21 @@ pub async fn create_account() -> Result { *pending = Some(keys.secret_key().to_bech32().map_err(|e| e.to_string())?); } - // Initialise the Nostr client - let _ = MY_KEYS.set(keys.clone()); - let _ = MY_PUBLIC_KEY.set(keys.public_key); + // Store secret key in the guarded vault, then construct the client with GuardedSigner + let public_key = keys.public_key; + MY_SECRET_KEY.store_from_keys(&keys); + let _ = MY_PUBLIC_KEY.set(public_key); + drop(keys); let client = Client::builder() - .signer(keys.clone()) + .signer(crate::guarded_key::GuardedSigner::new(public_key)) .opts(ClientOptions::new()) .monitor(Monitor::new(1024)) .build(); NOSTR_CLIENT.set(client).unwrap(); // Add our profile (at least, the npub of it) to our state - let npub = keys.public_key.to_bech32().map_err(|e| e.to_string())?; + let npub = public_key.to_bech32().map_err(|e| e.to_string())?; let mut profile = Profile::new(); profile.flags.set_mine(true); STATE.lock().await.insert_or_replace_profile(&npub, profile); @@ -455,27 +459,30 @@ pub async fn login_from_stored_key(password: Option) -> Result(handle: AppHandle) -> Result<(), /// Inner work for disable_encryption — separated so the outer function /// can guarantee the processing gate is always reopened. fn disable_encryption_work(handle: &AppHandle) -> Result<(), String> { - // Read the encryption key - let key: [u8; 32] = { - let guard = crate::ENCRYPTION_KEY.read().unwrap(); - (*guard).ok_or("No encryption key available".to_string())? - }; + // Read the encryption key from the guarded vault + let mut key: [u8; 32] = crate::ENCRYPTION_KEY.get() + .ok_or("No encryption key available".to_string())?; // Run transactional migration (all-or-nothing via SQLite transaction, audit C3/C4) - disable_encryption_transactional(handle, &key)?; + let result = disable_encryption_transactional(handle, &key); - // Clear the encryption key from memory (only after successful commit) - { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - *guard = None; + // Zeroize local key copy + key.zeroize(); + + // Clear the guarded vault (only after successful commit) + if result.is_ok() { + crate::ENCRYPTION_KEY.clear(); } - Ok(()) + result } /// Enable encryption - bulk encrypt all plaintext content @@ -181,10 +182,7 @@ pub async fn enable_encryption( ) -> Result<(), String> { // Derive key from credential (this is the slow Argon2 step) let key = crate::crypto::hash_pass(credential).await; - { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - *guard = Some(key); - } + crate::ENCRYPTION_KEY.set(key); // Close the processing gate close_processing_gate(); @@ -204,10 +202,7 @@ pub async fn enable_encryption( // Transaction rolled back — database is still plaintext. // Clear key from memory so maybe_encrypt doesn't encrypt new events // while old events remain plaintext (would create mixed state). - { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - *guard = None; - } + crate::ENCRYPTION_KEY.clear(); Err(e) } } @@ -219,16 +214,15 @@ fn enable_encryption_work( handle: &AppHandle, security_type: &str, ) -> Result<(), String> { - // Read the encryption key - let key: [u8; 32] = { - let guard = crate::ENCRYPTION_KEY.read().unwrap(); - (*guard).ok_or("No encryption key available".to_string())? - }; + // Read the encryption key from the guarded vault + let mut key: [u8; 32] = crate::ENCRYPTION_KEY.get() + .ok_or("No encryption key available".to_string())?; // Run transactional migration (all-or-nothing via SQLite transaction, audit C3/C4) - enable_encryption_transactional(handle, &key, security_type)?; + let result = enable_encryption_transactional(handle, &key, security_type); + key.zeroize(); - Ok(()) + result } // ============================================================================ @@ -852,11 +846,8 @@ pub async fn rekey_encryption( match result { Ok(()) => { - // Update global ENCRYPTION_KEY to new key ONLY after successful commit - { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - *guard = Some(new_key); - } + // Update guarded vault to new key ONLY after successful commit + crate::ENCRYPTION_KEY.set(new_key); let _ = handle.emit("encryption_migration_complete", ()); println!("[Rekey] Re-keying complete"); Ok(()) diff --git a/src-tauri/src/commands/mls.rs b/src-tauri/src/commands/mls.rs index 02ffcca1..96694294 100644 --- a/src-tauri/src/commands/mls.rs +++ b/src-tauri/src/commands/mls.rs @@ -144,7 +144,7 @@ pub async fn regenerate_device_keypackage(cache: bool) -> Result [u8; 32] { pub async fn internal_encrypt(mut input: String, password: Option) -> String { // Hash our password with Argon2 and use it as the key let mut key: [u8; 32] = if password.is_none() { - // Read the cached key - let guard = crate::ENCRYPTION_KEY.read().unwrap(); - *guard.as_ref().expect("Encryption key must be set") + // Read from the guarded vault + crate::ENCRYPTION_KEY.get().expect("Encryption key must be set") } else { hash_pass(password.unwrap()).await }; @@ -122,12 +121,9 @@ pub async fn internal_encrypt(mut input: String, password: Option) -> St buffer.extend_from_slice(&nonce_bytes); buffer.extend_from_slice(&ciphertext); - // Cache the key if not already set - { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - if guard.is_none() { - *guard = Some(key); - } + // Cache the key in the guarded vault if not already set + if !crate::ENCRYPTION_KEY.has_key() { + crate::ENCRYPTION_KEY.set(key); } // Zeroize the local key copy @@ -142,15 +138,14 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R // Check if we're using a password before we potentially move it let has_password = password.is_some(); - // Get the key - either from password or cached + // Get the key - either from password or guarded vault let mut key: [u8; 32] = if let Some(pass) = password { // Hash the password hash_pass(pass).await } else { - // Try to use cached key - let guard = crate::ENCRYPTION_KEY.read().unwrap(); - match guard.as_ref() { - Some(k) => *k, + // Read from the guarded vault + match crate::ENCRYPTION_KEY.get() { + Some(k) => k, None => return Err(()), } }; @@ -178,12 +173,9 @@ pub async fn internal_decrypt(ciphertext: String, password: Option) -> R Err(_) => { key.zeroize(); return Err(()) } }; - // Cache the key if needed - only set if we came from password path - if has_password { - let mut guard = crate::ENCRYPTION_KEY.write().unwrap(); - if guard.is_none() { - *guard = Some(key); - } + // Cache the key in the guarded vault if needed - only set if we came from password path + if has_password && !crate::ENCRYPTION_KEY.has_key() { + crate::ENCRYPTION_KEY.set(key); } // Zeroize the local key copy diff --git a/src-tauri/src/guarded_key.rs b/src-tauri/src/guarded_key.rs new file mode 100644 index 00000000..98a91731 --- /dev/null +++ b/src-tauri/src/guarded_key.rs @@ -0,0 +1,889 @@ +//! Memory-hardened key storage: 128 indistinguishable arrays with decoy writes. +//! +//! The real 32-byte key is XOR-split into 4 shares (16 `usize` values on 64-bit), +//! scattered across 128 static arrays of 4,096 entries each. 123 arrays are pure +//! decoys — provably indistinguishable from the 5 real arrays. +//! +//! Defense layers: +//! 1. **Secret splitting**: key XOR-split into 4 shares +//! 2. **128 indistinguishable arrays**: 5 real + 123 decoy, all same size, all +//! OsRng-initialized, all modified identically during set()/clear() +//! 3. **No heap allocations**: zero pointers, zero fingerprints, zero mlock +//! 4. **Scattered positions**: seed, multipliers, and share data each placed at +//! (array, slot) positions derived from ASLR instance address +//! 5. **Decoy writes**: during set(), ALL 128 arrays receive ~16 random writes, +//! making real writes indistinguishable via snapshot diffing +//! 6. **Zero side-channel**: `AtomicUsize::load` during get() — zero writes +//! 7. **Zero searchable constants**: all multipliers derived from ASLR addresses, +//! all modular arithmetic uses power-of-2 AND masks. The vault code compiles +//! to generic LDR + MUL + AND + EOR + LSR — indistinguishable from the +//! thousands of hash/cipher/RNG functions in the binary. +//! +//! Security tiers: +//! **Without source code** (info-stealers, forensic tools): immune. +//! Arrays are information-theoretically indistinguishable. +//! +//! **With source code + memory dump**: attacker must brute-force the ASLR +//! instance address (~2.5M candidates on macOS, ~268M on Linux). +//! Computational hardening makes each attempt ~25 μs. +//! Estimated: ~1 min (macOS) to ~2 hrs (Linux), single-core. +//! +//! The vault is the second layer — anti-debug protections (PT_DENY_ATTACH, +//! PR_SET_DUMPABLE, DACL) prevent obtaining the dump in the first place. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use rand::RngCore; +use zeroize::Zeroize; + +/// Entries per array — 4096 is common for crypto lookup tables (S-boxes, hash +/// constants), maximizing false positives during memory scanning. +const ARRAY_SIZE: usize = 4096; +/// Total arrays: 3 config (seed, mul1, mul2) + 4 shares scattered across random arrays + decoys. +/// Power-of-2: `% 128` compiles to `AND #0x7f` — hundreds of hits in any binary. +/// Non-power-of-2 would use a unique magic-multiply constant (trivially searchable). +const ARRAY_COUNT: usize = 128; +/// Number of real XOR shares the key is split into. +const NUM_SHARES: usize = 4; +/// `usize` values per 32-byte share (4 on 64-bit, 8 on 32-bit). +const USIZES_PER_SHARE: usize = 32 / std::mem::size_of::(); +/// Total entries used by one key's share data. +const SHARE_ENTRIES: usize = NUM_SHARES * USIZES_PER_SHARE; + +/// 128 vault arrays — all identical, all OsRng-initialized, all modified during set(). +/// Memory: 128 × 4096 × size_of::() = 4 MB on 64-bit, 2 MB on 32-bit. +static VAULTS: [[AtomicUsize; ARRAY_SIZE]; ARRAY_COUNT] = { + const ZERO: AtomicUsize = AtomicUsize::new(0); + const ROW: [AtomicUsize; ARRAY_SIZE] = [ZERO; ARRAY_SIZE]; + [ROW; ARRAY_COUNT] +}; + +/// Initialize all 128 arrays with random values (once per process). +/// Every entry across all arrays is filled — real and decoy arrays are identical. +fn ensure_vaults() { + if VAULTS[0][0].load(Ordering::Relaxed) == 0 { + + let mut rng = rand::rngs::OsRng; + for row in VAULTS.iter() { + for slot in row.iter() { + let mut val = rng.next_u64() as usize; + if val == 0 { val = 1; } + let _ = slot.compare_exchange(0, val, Ordering::SeqCst, Ordering::Relaxed); + } + } + } +} + +/// A position within the vault: (array index, slot index). +#[derive(Clone, Copy, PartialEq)] +#[cfg_attr(test, derive(Debug))] +struct VaultPos { + array: usize, + slot: usize, +} + +/// Derive the mixing iteration count from ASLR addresses — ZERO constants in the binary. +/// Range: 4096..8191. Changes every launch. `& 4095` compiles to `AND #0xFFF` (hundreds +/// of hits in any binary). No fixed loop bound for RE engineers to search for. +#[inline] +fn mix_iterations(instance_addr: usize) -> usize { + let base = VAULTS.as_ptr() as usize; + let h = (instance_addr ^ base).wrapping_mul(instance_addr | 1); + 4096 + ((h >> 7) & 4095) +} + +/// Hardened hash mixing — runtime-determined iterations of shift-multiply-XOR. +/// ZERO compile-time constants — both multipliers AND iteration count derived +/// from ASLR addresses. Compiles to a tight loop of generic MUL + EOR + LSR. +#[inline] +fn addr_mix(mut h: u64, m1: u64, m2: u64, iterations: usize) -> u64 { + for _ in 0..iterations { + h ^= h >> 33; + h = h.wrapping_mul(m1); + h ^= h >> 29; + h = h.wrapping_mul(m2); + h ^= h >> 31; + } + h +} + +/// Derive the 3 configuration positions (seed, mul1, mul2) from the instance +/// address alone. No dependency on stored values — breaks the chicken-and-egg. +/// Uses instance address + VAULTS base address (both ASLR'd, both change per launch). +/// ZERO searchable constants — multipliers derived from the two ASLR addresses. +/// Guaranteed collision-free: each position is unique (rehash on collision). +fn config_positions(instance_addr: usize) -> (VaultPos, VaultPos, VaultPos) { + let base = VAULTS.as_ptr() as usize; + // Derive multipliers from ASLR addresses — forced odd for mixing quality. + // These change every launch. No constants in the binary. + let m1 = (instance_addr as u64) | 1; + let m2 = (base as u64) | 1; + let iters = mix_iterations(instance_addr); + let mut h = addr_mix((instance_addr as u64) ^ (base as u64).rotate_left(19), m1, m2, iters); + + let mut positions = [VaultPos { array: 0, slot: 0 }; 3]; + for i in 0..3 { + loop { + let candidate = VaultPos { + array: ((h >> 32) as usize) & (ARRAY_COUNT - 1), + slot: (h as usize) & (ARRAY_SIZE - 1), + }; + if !positions[..i].contains(&candidate) { + positions[i] = candidate; + h = addr_mix(h, m1, m2, iters); + break; + } + h = addr_mix(h, m1, m2, iters); + } + } + + (positions[0], positions[1], positions[2]) +} + +/// Derive share data positions. Uses the seed + multipliers read from the vault +/// (which are at config-derived positions) + instance address. +/// Returns SHARE_ENTRIES (array, slot) pairs — each guaranteed unique and +/// non-overlapping with config positions (rehash on collision). +fn share_positions(instance_addr: usize) -> [VaultPos; SHARE_ENTRIES] { + let (seed_pos, mul1_pos, mul2_pos) = config_positions(instance_addr); + let seed = VAULTS[seed_pos.array][seed_pos.slot].load(Ordering::Relaxed) as u64; + let mul1 = VAULTS[mul1_pos.array][mul1_pos.slot].load(Ordering::Relaxed) as u64; + let mul2 = VAULTS[mul2_pos.array][mul2_pos.slot].load(Ordering::Relaxed) as u64; + + let mut h = seed ^ (instance_addr as u64).rotate_left(23); + let mut positions = [VaultPos { array: 0, slot: 0 }; SHARE_ENTRIES]; + let config = [seed_pos, mul1_pos, mul2_pos]; + + for i in 0..SHARE_ENTRIES { + loop { + h ^= h >> 17; + h = h.wrapping_mul(mul1 | 1); + h ^= h >> 13; + h = h.wrapping_mul(mul2 | 1); + h ^= h >> 16; + let candidate = VaultPos { + array: ((h >> 32) as usize) & (ARRAY_COUNT - 1), + slot: (h as usize) & (ARRAY_SIZE - 1), + }; + if !config.contains(&candidate) && !positions[..i].contains(&candidate) { + positions[i] = candidate; + break; + } + // Collision: hash state already advanced, loop retries with new h + } + } + + positions +} + +/// Write ~16 random entries to EVERY array (real + decoy), excluding protected positions. +/// Protected positions belong to other active GuardedKey instances — overwriting them would +/// corrupt the other key's share/config data. Snapshot-diffing still shows identical change +/// patterns across all 128 arrays (excluded slots are ≤19 out of 524,288 — invisible). +fn write_decoys(protected: &[VaultPos]) { + + let mut rng = rand::rngs::OsRng; + for (array_idx, row) in VAULTS.iter().enumerate() { + for _ in 0..SHARE_ENTRIES { + let mut slot = (rng.next_u64() as usize) & (ARRAY_SIZE - 1); + // Reroll if this hits another key's data (~0.004% chance, essentially never) + while protected.iter().any(|p| p.array == array_idx && p.slot == slot) { + slot = (rng.next_u64() as usize) & (ARRAY_SIZE - 1); + } + let mut val = rng.next_u64() as usize; + if val == 0 { val = 1; } + row[slot].store(val, Ordering::Release); + } + } +} + +/// Memory-hardened key vault backed by 128 indistinguishable static arrays. +pub struct GuardedKey { + /// Non-zero when a key is stored. Stores a random non-zero value (not 0/1) + /// so it looks like any other random data in `__DATA`. + active: AtomicUsize, +} + +impl GuardedKey { + pub const fn empty() -> Self { + Self { active: AtomicUsize::new(0) } + } + + #[inline] + fn instance_addr(&self) -> usize { + &self.active as *const _ as usize + } + + /// Collect protected vault positions from other active GuardedKey instances. + /// Prevents write_decoys from corrupting another key's share/config data. + fn collect_other_protected(&self) -> ([VaultPos; 3 + SHARE_ENTRIES], usize) { + let keys: [&GuardedKey; 2] = [&crate::MY_SECRET_KEY, &crate::ENCRYPTION_KEY]; + let mut buf = [VaultPos { array: 0, slot: 0 }; 3 + SHARE_ENTRIES]; + let mut n = 0; + for &key in &keys { + if std::ptr::eq(key, self) || !key.has_key() { continue; } + let addr = key.instance_addr(); + let (s, m1, m2) = config_positions(addr); + buf[n] = s; n += 1; + buf[n] = m1; n += 1; + buf[n] = m2; n += 1; + for &pos in share_positions(addr).iter() { + buf[n] = pos; + n += 1; + } + } + (buf, n) + } + + /// Extract the secret key from a `Keys` struct, store it in the vault, + /// and zeroize the intermediate bytes. One-liner replacement for the + /// repeated extract → set → zeroize pattern across login paths. + #[inline] + pub fn store_from_keys(&self, keys: &nostr_sdk::Keys) { + let mut sk_bytes = keys.secret_key().secret_bytes(); + self.set(sk_bytes); + sk_bytes.zeroize(); + } + + /// Store a key. XOR-split into 4 shares scattered across the 128 arrays, + /// with decoy writes to ALL arrays so real writes are indistinguishable. + pub fn set(&self, mut key: [u8; 32]) { + + let mut rng = rand::rngs::OsRng; + ensure_vaults(); + + // Protect other active key's positions from decoy writes + let (protected, pcount) = self.collect_other_protected(); + + // Write decoys FIRST — random noise across all 128 arrays. + // Excludes other keys' positions. Real writes below overwrite any decoy + // that landed on OUR slots, guaranteeing all keys' data survives intact. + write_decoys(&protected[..pcount]); + + // Force multiplier entries odd (mixing quality). + // ~50% of all entries are already odd, so this isn't a fingerprint. + let (_, mul1_pos, mul2_pos) = config_positions(self.instance_addr()); + let v = VAULTS[mul1_pos.array][mul1_pos.slot].load(Ordering::Relaxed); + VAULTS[mul1_pos.array][mul1_pos.slot].store(v | 1, Ordering::Relaxed); + let v = VAULTS[mul2_pos.array][mul2_pos.slot].load(Ordering::Relaxed); + VAULTS[mul2_pos.array][mul2_pos.slot].store(v | 1, Ordering::Relaxed); + + // XOR-split the key into NUM_SHARES random shares + let mut shares = [[0u8; 32]; NUM_SHARES]; + for share in shares.iter_mut().take(NUM_SHARES - 1) { + rng.fill_bytes(share); + } + shares[NUM_SHARES - 1] = key; + for i in 0..NUM_SHARES - 1 { + for j in 0..32 { + shares[NUM_SHARES - 1][j] ^= shares[i][j]; + } + } + key.zeroize(); + + // Write share data to derived positions (after decoys, so real data survives) + let positions = share_positions(self.instance_addr()); + for (share_idx, share) in shares.iter().enumerate() { + for u_idx in 0..USIZES_PER_SHARE { + let byte_off = u_idx * std::mem::size_of::(); + let val = usize::from_ne_bytes( + share[byte_off..byte_off + std::mem::size_of::()] + .try_into().unwrap() + ); + let pos = positions[share_idx * USIZES_PER_SHARE + u_idx]; + VAULTS[pos.array][pos.slot].store(val, Ordering::Release); + } + } + for share in shares.iter_mut() { share.zeroize(); } + + // Mark active with random non-zero value + let mut marker = rng.next_u64() as usize; + if marker == 0 { marker = 1; } + self.active.store(marker, Ordering::Release); + } + + /// Recover the key. Zero writes — invisible to snapshot diffing. + pub fn get(&self) -> Option<[u8; 32]> { + if self.active.load(Ordering::Acquire) == 0 { + return None; + } + + let positions = share_positions(self.instance_addr()); + let mut key = [0u8; 32]; + + for share_idx in 0..NUM_SHARES { + let mut share = [0u8; 32]; + for u_idx in 0..USIZES_PER_SHARE { + let pos = positions[share_idx * USIZES_PER_SHARE + u_idx]; + let val = VAULTS[pos.array][pos.slot].load(Ordering::Acquire); + let byte_off = u_idx * std::mem::size_of::(); + share[byte_off..byte_off + std::mem::size_of::()] + .copy_from_slice(&val.to_ne_bytes()); + } + for (a, b) in key.iter_mut().zip(share.iter()) { + *a ^= *b; + } + } + + Some(key) + } + + /// Clear the key. Overwrites shares with random values, writes decoys to all arrays. + pub fn clear(&self) { + // Set inactive FIRST — any concurrent get() will return None + if self.active.swap(0, Ordering::SeqCst) != 0 { + + let mut rng = rand::rngs::OsRng; + let positions = share_positions(self.instance_addr()); + for pos in &positions { + let mut val = rng.next_u64() as usize; + if val == 0 { val = 1; } + VAULTS[pos.array][pos.slot].store(val, Ordering::Release); + } + let (protected, pcount) = self.collect_other_protected(); + write_decoys(&protected[..pcount]); + } + } + + pub fn has_key(&self) -> bool { + self.active.load(Ordering::Acquire) != 0 + } + + pub fn to_keys(&self) -> Option { + let mut bytes = self.get()?; + let result = nostr_sdk::SecretKey::from_slice(&bytes); + bytes.zeroize(); + Some(nostr_sdk::Keys::new(result.ok()?)) + } +} + +// ============================================================================ +// GuardedSigner — NostrSigner backed by a GuardedKey vault +// ============================================================================ + +/// A `NostrSigner` that reads the secret key from a `GuardedKey` vault on every +/// operation. The key exists in plaintext only for microseconds during signing, +/// then is zeroized on drop. No permanent key copies in process memory. +#[derive(Debug)] +pub struct GuardedSigner { + public_key: nostr_sdk::PublicKey, +} + +impl GuardedSigner { + pub fn new(public_key: nostr_sdk::PublicKey) -> Self { + Self { public_key } + } + + fn temp_keys(&self) -> Result { + crate::MY_SECRET_KEY.to_keys() + .ok_or_else(|| nostr_sdk::prelude::SignerError::from("Secret key not available")) + } +} + +impl nostr_sdk::prelude::NostrSigner for GuardedSigner { + fn backend(&self) -> nostr_sdk::prelude::SignerBackend { + nostr_sdk::prelude::SignerBackend::Keys + } + + fn get_public_key(&self) -> nostr_sdk::prelude::BoxedFuture> { + let pk = self.public_key; + Box::pin(async move { Ok(pk) }) + } + + fn sign_event(&self, unsigned: nostr_sdk::UnsignedEvent) -> nostr_sdk::prelude::BoxedFuture> { + let keys = self.temp_keys(); + Box::pin(async move { + let keys = keys?; + unsigned.sign_with_keys(&keys).map_err(nostr_sdk::prelude::SignerError::backend) + }) + } + + fn nip04_encrypt<'a>( + &'a self, public_key: &'a nostr_sdk::PublicKey, content: &'a str, + ) -> nostr_sdk::prelude::BoxedFuture<'a, Result> { + let keys = self.temp_keys(); + Box::pin(async move { let keys = keys?; keys.nip04_encrypt(public_key, content).await }) + } + + fn nip04_decrypt<'a>( + &'a self, public_key: &'a nostr_sdk::PublicKey, encrypted_content: &'a str, + ) -> nostr_sdk::prelude::BoxedFuture<'a, Result> { + let keys = self.temp_keys(); + Box::pin(async move { let keys = keys?; keys.nip04_decrypt(public_key, encrypted_content).await }) + } + + fn nip44_encrypt<'a>( + &'a self, public_key: &'a nostr_sdk::PublicKey, content: &'a str, + ) -> nostr_sdk::prelude::BoxedFuture<'a, Result> { + let keys = self.temp_keys(); + Box::pin(async move { let keys = keys?; keys.nip44_encrypt(public_key, content).await }) + } + + fn nip44_decrypt<'a>( + &'a self, public_key: &'a nostr_sdk::PublicKey, payload: &'a str, + ) -> nostr_sdk::prelude::BoxedFuture<'a, Result> { + let keys = self.temp_keys(); + Box::pin(async move { let keys = keys?; keys.nip44_decrypt(public_key, payload).await }) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// All tests share VAULTS and the two global keys — serialize them. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + /// Fast reset: mark both global keys inactive without full clear overhead. + fn reset() { + crate::MY_SECRET_KEY.active.store(0, Ordering::SeqCst); + crate::ENCRYPTION_KEY.active.store(0, Ordering::SeqCst); + ensure_vaults(); + } + + /// Generate a deterministic test key from a seed byte. + fn test_key(seed: u8) -> [u8; 32] { + let mut k = [0u8; 32]; + for (i, b) in k.iter_mut().enumerate() { + *b = seed.wrapping_add(i as u8).wrapping_mul(37).wrapping_add(7); + } + k + } + + // ================================================================ + // Basic operations + // ================================================================ + + #[test] + fn set_get_roundtrip() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let key = test_key(42); + crate::MY_SECRET_KEY.set(key); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(key)); + } + + #[test] + fn set_get_1000_iterations() { + let _l = TEST_LOCK.lock().unwrap(); + for i in 0..1000u16 { + reset(); + let key = test_key((i ^ (i >> 3)) as u8); + crate::MY_SECRET_KEY.set(key); + assert_eq!( + crate::MY_SECRET_KEY.get(), Some(key), + "Roundtrip failed at iteration {i}" + ); + } + } + + #[test] + fn empty_returns_none() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + assert_eq!(crate::MY_SECRET_KEY.get(), None); + assert_eq!(crate::ENCRYPTION_KEY.get(), None); + } + + #[test] + fn has_key_lifecycle() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + assert!(!crate::MY_SECRET_KEY.has_key()); + crate::MY_SECRET_KEY.set(test_key(1)); + assert!(crate::MY_SECRET_KEY.has_key()); + crate::MY_SECRET_KEY.clear(); + assert!(!crate::MY_SECRET_KEY.has_key()); + } + + #[test] + fn clear_returns_none() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + crate::MY_SECRET_KEY.set(test_key(99)); + assert!(crate::MY_SECRET_KEY.get().is_some()); + crate::MY_SECRET_KEY.clear(); + assert_eq!(crate::MY_SECRET_KEY.get(), None); + } + + #[test] + fn set_overwrites_previous() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let a = test_key(10); + let b = test_key(20); + crate::MY_SECRET_KEY.set(a); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(a)); + crate::MY_SECRET_KEY.set(b); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(b)); + } + + #[test] + fn clear_idempotent() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + crate::MY_SECRET_KEY.clear(); + crate::MY_SECRET_KEY.clear(); + assert_eq!(crate::MY_SECRET_KEY.get(), None); + crate::MY_SECRET_KEY.set(test_key(5)); + crate::MY_SECRET_KEY.clear(); + crate::MY_SECRET_KEY.clear(); + assert_eq!(crate::MY_SECRET_KEY.get(), None); + } + + #[test] + fn encryption_key_basic() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let key = test_key(0xEE); + crate::ENCRYPTION_KEY.set(key); + assert_eq!(crate::ENCRYPTION_KEY.get(), Some(key)); + crate::ENCRYPTION_KEY.clear(); + assert_eq!(crate::ENCRYPTION_KEY.get(), None); + } + + // ================================================================ + // Cross-key protection — 500 iterations each. + // Before the fix, these had ~7% failure rate per iteration. + // With 500 iterations the old bug would fail with P > 99.9999%. + // ================================================================ + + #[test] + fn cross_key_set_then_set_500() { + let _l = TEST_LOCK.lock().unwrap(); + let key_a = test_key(0xAA); + let key_b = test_key(0xBB); + for i in 0..500 { + reset(); + crate::MY_SECRET_KEY.set(key_a); + crate::ENCRYPTION_KEY.set(key_b); + assert_eq!( + crate::MY_SECRET_KEY.get(), Some(key_a), + "MY_SECRET_KEY corrupted at iteration {i}" + ); + assert_eq!( + crate::ENCRYPTION_KEY.get(), Some(key_b), + "ENCRYPTION_KEY corrupted at iteration {i}" + ); + } + } + + #[test] + fn cross_key_reverse_order_500() { + let _l = TEST_LOCK.lock().unwrap(); + let key_a = test_key(0xCC); + let key_b = test_key(0xDD); + for i in 0..500 { + reset(); + crate::ENCRYPTION_KEY.set(key_b); + crate::MY_SECRET_KEY.set(key_a); + assert_eq!( + crate::ENCRYPTION_KEY.get(), Some(key_b), + "ENCRYPTION_KEY corrupted at iteration {i}" + ); + assert_eq!( + crate::MY_SECRET_KEY.get(), Some(key_a), + "MY_SECRET_KEY corrupted at iteration {i}" + ); + } + } + + #[test] + fn cross_key_clear_preserves_other_500() { + let _l = TEST_LOCK.lock().unwrap(); + let key_a = test_key(0x11); + let key_b = test_key(0x22); + for i in 0..500 { + // Clear MY_SECRET_KEY, verify ENCRYPTION_KEY survives + reset(); + crate::MY_SECRET_KEY.set(key_a); + crate::ENCRYPTION_KEY.set(key_b); + crate::MY_SECRET_KEY.clear(); + assert_eq!( + crate::ENCRYPTION_KEY.get(), Some(key_b), + "EK corrupted after SK.clear() at iteration {i}" + ); + // Clear ENCRYPTION_KEY, verify MY_SECRET_KEY survives + reset(); + crate::MY_SECRET_KEY.set(key_a); + crate::ENCRYPTION_KEY.set(key_b); + crate::ENCRYPTION_KEY.clear(); + assert_eq!( + crate::MY_SECRET_KEY.get(), Some(key_a), + "SK corrupted after EK.clear() at iteration {i}" + ); + } + } + + #[test] + fn cross_key_alternating_500() { + let _l = TEST_LOCK.lock().unwrap(); + for i in 0..500u16 { + reset(); + let ka = test_key(i as u8); + let kb = test_key(!(i as u8)); + crate::MY_SECRET_KEY.set(ka); + crate::ENCRYPTION_KEY.set(kb); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(ka), "SK wrong at iter {i}"); + assert_eq!(crate::ENCRYPTION_KEY.get(), Some(kb), "EK wrong at iter {i}"); + crate::MY_SECRET_KEY.clear(); + assert_eq!(crate::ENCRYPTION_KEY.get(), Some(kb), "EK wrong after SK clear at iter {i}"); + } + } + + /// Stress: alternating set order, different keys each round, 1000 iterations. + #[test] + fn stress_both_keys_1000() { + let _l = TEST_LOCK.lock().unwrap(); + for i in 0..1000u32 { + reset(); + let ka = test_key((i & 0xFF) as u8); + let kb = test_key(!((i & 0xFF) as u8)); + if i % 2 == 0 { + crate::MY_SECRET_KEY.set(ka); + crate::ENCRYPTION_KEY.set(kb); + } else { + crate::ENCRYPTION_KEY.set(kb); + crate::MY_SECRET_KEY.set(ka); + } + assert_eq!(crate::MY_SECRET_KEY.get(), Some(ka), "SK wrong at iter {i}"); + assert_eq!(crate::ENCRYPTION_KEY.get(), Some(kb), "EK wrong at iter {i}"); + } + } + + // ================================================================ + // Position derivation + // ================================================================ + + #[test] + fn config_positions_all_unique() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + for addr in (0x1000..0x2000usize).step_by(8) { + let (a, b, c) = config_positions(addr); + assert_ne!(a, b, "config collision a==b at addr {addr:#x}"); + assert_ne!(a, c, "config collision a==c at addr {addr:#x}"); + assert_ne!(b, c, "config collision b==c at addr {addr:#x}"); + } + } + + #[test] + fn share_positions_all_unique() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + for addr in (0x2000..0x2100usize).step_by(8) { + let positions = share_positions(addr); + for i in 0..SHARE_ENTRIES { + for j in (i + 1)..SHARE_ENTRIES { + assert_ne!( + positions[i], positions[j], + "share collision [{i}]==[{j}] at addr {addr:#x}" + ); + } + } + } + } + + #[test] + fn share_positions_no_config_overlap() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + for addr in (0x3000..0x3100usize).step_by(8) { + let (s, m1, m2) = config_positions(addr); + let config = [s, m1, m2]; + let shares = share_positions(addr); + for (i, pos) in shares.iter().enumerate() { + assert!( + !config.contains(pos), + "share[{i}] collides with config at addr {addr:#x}" + ); + } + } + } + + #[test] + fn positions_deterministic() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + let addr = crate::MY_SECRET_KEY.instance_addr(); + let cfg1 = config_positions(addr); + let cfg2 = config_positions(addr); + assert_eq!(cfg1, cfg2); + let sp1 = share_positions(addr); + let sp2 = share_positions(addr); + assert_eq!(sp1, sp2); + } + + #[test] + fn all_positions_in_bounds() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + for addr in (0x4000..0x4200usize).step_by(8) { + let (a, b, c) = config_positions(addr); + for p in [a, b, c] { + assert!(p.array < ARRAY_COUNT); + assert!(p.slot < ARRAY_SIZE); + } + for p in share_positions(addr) { + assert!(p.array < ARRAY_COUNT); + assert!(p.slot < ARRAY_SIZE); + } + } + } + + // ================================================================ + // Internals + // ================================================================ + + #[test] + fn mix_iterations_in_range() { + for addr in 0..10000usize { + let n = mix_iterations(addr); + assert!((4096..=8191).contains(&n), "mix_iterations({addr}) = {n}"); + } + } + + #[test] + fn addr_mix_zero_iterations_is_identity() { + let h: u64 = 0xDEADBEEFCAFEBABE; + assert_eq!(addr_mix(h, 123, 456, 0), h); + } + + #[test] + fn addr_mix_varies_output() { + let a = addr_mix(1, 3, 5, 10); + let b = addr_mix(2, 3, 5, 10); + let c = addr_mix(1, 7, 5, 10); + let d = addr_mix(1, 3, 11, 10); + assert_ne!(a, b); + assert_ne!(a, c); + assert_ne!(a, d); + } + + #[test] + fn ensure_vaults_all_nonzero() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + for (r, row) in VAULTS.iter().enumerate() { + for (s, slot) in row.iter().enumerate() { + assert_ne!( + slot.load(Ordering::Relaxed), 0, + "VAULTS[{r}][{s}] is zero after ensure_vaults" + ); + } + } + } + + #[test] + fn ensure_vaults_idempotent() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + let samples: Vec<_> = (0..20) + .map(|i| { + let r = i * 13 % ARRAY_COUNT; + let s = i * 397 % ARRAY_SIZE; + (r, s, VAULTS[r][s].load(Ordering::Relaxed)) + }) + .collect(); + ensure_vaults(); + for (r, s, val) in &samples { + assert_eq!( + VAULTS[*r][*s].load(Ordering::Relaxed), *val, + "ensure_vaults changed VAULTS[{r}][{s}]" + ); + } + } + + /// Run write_decoys 500 times with protected positions — verify they are NEVER overwritten. + /// Without exclusion, P(at least one hit) per position ≈ 86%. With 6 positions: + /// P(all survive unprotected) ≈ 0.14^6 ≈ 0.00075%. This test catches the bug with certainty. + #[test] + fn write_decoys_respects_exclusions_500() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + let protected = [ + VaultPos { array: 0, slot: 100 }, + VaultPos { array: 0, slot: 200 }, + VaultPos { array: 50, slot: 2000 }, + VaultPos { array: 50, slot: 3000 }, + VaultPos { array: 100, slot: 500 }, + VaultPos { array: 127, slot: 4095 }, + ]; + let before: Vec = protected.iter() + .map(|p| VAULTS[p.array][p.slot].load(Ordering::Relaxed)) + .collect(); + for _ in 0..500 { + write_decoys(&protected); + } + for (i, p) in protected.iter().enumerate() { + assert_eq!( + VAULTS[p.array][p.slot].load(Ordering::Relaxed), + before[i], + "Protected position ({}, {}) overwritten after 500 rounds", + p.array, p.slot + ); + } + } + + #[test] + fn write_decoys_empty_exclusion_works() { + let _l = TEST_LOCK.lock().unwrap(); + ensure_vaults(); + write_decoys(&[]); + } + + // ================================================================ + // Edge cases + // ================================================================ + + #[test] + fn zero_key_roundtrip() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let key = [0u8; 32]; + crate::MY_SECRET_KEY.set(key); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(key)); + } + + #[test] + fn max_key_roundtrip() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let key = [0xFFu8; 32]; + crate::MY_SECRET_KEY.set(key); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(key)); + } + + #[test] + fn to_keys_roundtrip() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let mut sk_bytes = [0u8; 32]; + sk_bytes[31] = 1; // scalar = 1, valid secp256k1 key + crate::MY_SECRET_KEY.set(sk_bytes); + let keys = crate::MY_SECRET_KEY.to_keys(); + assert!(keys.is_some(), "to_keys returned None for valid key"); + assert_eq!(keys.unwrap().secret_key().secret_bytes(), sk_bytes); + } + + #[test] + fn to_keys_none_when_empty() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + assert!(crate::MY_SECRET_KEY.to_keys().is_none()); + } + + #[test] + fn store_from_keys_roundtrip() { + let _l = TEST_LOCK.lock().unwrap(); + reset(); + let keys = nostr_sdk::Keys::generate(); + let expected = keys.secret_key().secret_bytes(); + crate::MY_SECRET_KEY.store_from_keys(&keys); + assert_eq!(crate::MY_SECRET_KEY.get(), Some(expected)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8d3755a9..d225add2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ use tauri::Manager; mod macros; mod crypto; +mod guarded_key; mod db; @@ -85,7 +86,7 @@ mod simd; mod state; // Re-export commonly used state items at crate root for backwards compatibility pub(crate) use state::{ - TAURI_APP, NOSTR_CLIENT, MY_KEYS, MY_PUBLIC_KEY, STATE, + TAURI_APP, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, TRUSTED_RELAYS, active_trusted_relays, NOTIFIED_WELCOMES, WRAPPER_ID_CACHE, MNEMONIC_SEED, ENCRYPTION_KEY, PENDING_NSEC, PENDING_INVITE, get_blossom_servers, PendingInviteAcceptance, @@ -121,6 +122,84 @@ pub fn run() { } })); + // Harden against memory inspection and debugger attachment (release builds only). + // macOS: PT_DENY_ATTACH blocks task_for_pid + debugger attachment. + // Linux: PR_SET_DUMPABLE(0) blocks /proc/pid/mem + ptrace + core dumps. + // Windows: Strip PROCESS_VM_READ from process DACL, block unsigned DLL injection, + // and exit if a debugger is attached. + #[cfg(not(debug_assertions))] + { + #[cfg(target_os = "macos")] + unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0); } + + #[cfg(any(target_os = "linux", target_os = "android"))] + unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0); } + + #[cfg(target_os = "windows")] + unsafe { + extern "system" { + // kernel32.dll + fn IsDebuggerPresent() -> i32; + fn GetCurrentProcess() -> isize; + fn SetProcessMitigationPolicy(policy: u32, buf: *const u8, len: usize) -> i32; + // advapi32.dll — strip memory-read from process handle + fn SetSecurityInfo( + handle: isize, object_type: u32, info: u32, + owner: *const u8, group: *const u8, dacl: *const u8, sacl: *const u8, + ) -> u32; + fn SetEntriesInAclA( + count: u32, entries: *const ExplicitAccessA, + old_acl: *const u8, new_acl: *mut *mut u8, + ) -> u32; + } + + #[repr(C)] + struct ExplicitAccessA { + access_permissions: u32, + access_mode: u32, // DENY_ACCESS = 3 + inheritance: u32, + trustee: TrusteeA, + } + #[repr(C)] + struct TrusteeA { + multiple_trustee: *const u8, + multiple_trustee_operation: u32, + trustee_form: u32, // TRUSTEE_IS_NAME = 1 + trustee_type: u32, // TRUSTEE_IS_WELL_KNOWN_GROUP = 5 + trustee_name: *const u8, + } + + // 1. Exit if debugger is attached + if IsDebuggerPresent() != 0 { + std::process::exit(0); + } + + // 2. Block unsigned DLL injection (ProcessSignaturePolicy = 8, MicrosoftSignedOnly) + let signature_policy: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; // MicrosoftSignedOnly = 1 + SetProcessMitigationPolicy(8, signature_policy.as_ptr(), 4); + + // 3. Strip PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE from Everyone + let everyone = b"Everyone\0"; + let entry = ExplicitAccessA { + access_permissions: 0x0010 | 0x0020 | 0x0040, // VM_READ | VM_WRITE | DUP_HANDLE + access_mode: 3, // DENY_ACCESS + inheritance: 0, // NO_INHERITANCE + trustee: TrusteeA { + multiple_trustee: std::ptr::null(), + multiple_trustee_operation: 0, + trustee_form: 1, // TRUSTEE_IS_NAME + trustee_type: 5, // TRUSTEE_IS_WELL_KNOWN_GROUP + trustee_name: everyone.as_ptr(), + }, + }; + let mut new_dacl: *mut u8 = std::ptr::null_mut(); + if SetEntriesInAclA(1, &entry, std::ptr::null(), &mut new_dacl) == 0 && !new_dacl.is_null() { + // SE_KERNEL_OBJECT = 6, DACL_SECURITY_INFORMATION = 4 + SetSecurityInfo(GetCurrentProcess(), 6, 4, std::ptr::null(), std::ptr::null(), new_dacl, std::ptr::null()); + } + } + } + // Install rustls crypto provider before any TLS usage (required when both // 'ring' and 'aws-lc-rs' features are pulled by different transitive deps) let _ = rustls::crypto::ring::default_provider().install_default(); diff --git a/src-tauri/src/message/sending.rs b/src-tauri/src/message/sending.rs index 130e6f56..16e5a311 100644 --- a/src-tauri/src/message/sending.rs +++ b/src-tauri/src/message/sending.rs @@ -225,7 +225,7 @@ async fn encrypt_and_upload_mls_media( .map_err(|e| format!("MIP-04 encryption failed: {}", e))?; // Upload the encrypted data to Blossom - let signer = crate::MY_KEYS.get().expect("Keys not initialized").clone(); + let signer = crate::MY_SECRET_KEY.to_keys().expect("Keys not initialized"); let servers = crate::get_blossom_servers(); let url = crate::blossom::upload_blob_with_progress_and_failover( @@ -1005,7 +1005,7 @@ pub async fn message(receiver: String, content: String, replied_to: String, file } // Upload the file to the server - let signer = crate::MY_KEYS.get().expect("Keys not initialized").clone(); + let signer = crate::MY_SECRET_KEY.to_keys().expect("Keys not initialized"); let servers = crate::get_blossom_servers(); let file_size = enc_file.len(); // Clone the Arc outside the closure for use inside a seperate-threaded progress callback diff --git a/src-tauri/src/mls/messaging.rs b/src-tauri/src/mls/messaging.rs index 3eac9aa9..53941dc1 100644 --- a/src-tauri/src/mls/messaging.rs +++ b/src-tauri/src/mls/messaging.rs @@ -131,7 +131,7 @@ pub async fn send_mls_message(group_id: &str, rumor: nostr_sdk::UnsignedEvent, p wrapper_builder = wrapper_builder.tag(Tag::expiration(expiry_time)); // Build and sign the wrapper - let signer = crate::MY_KEYS.get().expect("Keys not initialized").clone(); + let signer = crate::MY_SECRET_KEY.to_keys().expect("Keys not initialized"); let wrapper_with_expiry = wrapper_builder.sign(&signer).await .map_err(|e| format!("Failed to sign wrapper with expiration: {}", e))?; diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 17fe5076..8d58eb08 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -742,7 +742,7 @@ pub async fn upload_avatar(filepath: String, upload_type: Option) -> Res .map_err(|_| "File type is not allowed for avatars (only images are permitted)")?; // Upload the file to the server using Blossom with automatic failover and progress - let signer = crate::MY_KEYS.get().expect("Keys not initialized").clone(); + let signer = crate::MY_SECRET_KEY.to_keys().expect("Keys not initialized"); let servers = crate::get_blossom_servers(); // Create progress callback that emits events to frontend diff --git a/src-tauri/src/state/globals.rs b/src-tauri/src/state/globals.rs index f70f6477..dca6a7d4 100644 --- a/src-tauri/src/state/globals.rs +++ b/src-tauri/src/state/globals.rs @@ -141,8 +141,9 @@ pub static MNEMONIC_SEED: std::sync::Mutex> = std::sync::Mutex::n pub static PENDING_NSEC: std::sync::Mutex> = std::sync::Mutex::new(None); /// Encryption key derived from user's PIN via Argon2. -/// Uses RwLock to allow clearing/updating (for PIN changes, encryption toggle). -pub static ENCRYPTION_KEY: std::sync::RwLock> = std::sync::RwLock::new(None); +/// Stored in a memory-hardened vault: XOR-split into 4 shares scattered across +/// 128 indistinguishable arrays. The key only exists briefly during encrypt/decrypt. +pub static ENCRYPTION_KEY: crate::guarded_key::GuardedKey = crate::guarded_key::GuardedKey::empty(); /// Cached encryption-enabled flag. Read by every maybe_encrypt/maybe_decrypt call. /// Default: false (safe — no encryption until explicitly initialized, avoids panic if @@ -176,9 +177,10 @@ pub fn init_encryption_enabled() { /// Global Nostr client instance pub static NOSTR_CLIENT: OnceLock = OnceLock::new(); -/// Cached signer keys — set once at login, never changes during a session. -/// `Keys` implements `NostrSigner`, so this can be used directly for signing. -pub static MY_KEYS: OnceLock = OnceLock::new(); +/// Secret key stored in a memory-hardened vault: XOR-split into 4 shares scattered +/// across 128 indistinguishable arrays. Use `MY_SECRET_KEY.to_keys()` for temporary +/// signing operations. The key only exists for microseconds during use. +pub static MY_SECRET_KEY: crate::guarded_key::GuardedKey = crate::guarded_key::GuardedKey::empty(); /// Cached public key — set once at login, never changes during a session. /// Avoids redundant async signer→get_public_key derivations. diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index 1c91e843..a21ed048 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -11,7 +11,7 @@ mod chat_state; pub mod stats; pub use globals::{ - TAURI_APP, NOSTR_CLIENT, MY_KEYS, MY_PUBLIC_KEY, STATE, + TAURI_APP, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, TRUSTED_RELAYS, active_trusted_relays, MNEMONIC_SEED, ENCRYPTION_KEY, PENDING_NSEC, PENDING_INVITE, NOTIFIED_WELCOMES, WRAPPER_ID_CACHE, From c8ab6ee6e27b824f2abea52cc52cda5a176e1ba5 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 03:32:20 +0000 Subject: [PATCH 013/417] perf(android): reduce background battery drain with scoped subs and single relay Background sync was connecting to all 4+ relays and subscribing to every MLS group message on the entire network, filtering client-side. This kept the mobile radio permanently active and wasted bandwidth/CPU on irrelevant traffic. Changes: - Scoped MLS subscriptions via 'h' tag: relay-side filtering by group ID, only our groups' messages are sent over the wire (foreground + background) - Single relay with failover: background connects to one relay instead of 4-5, tries each candidate in priority order (user custom relays first, then non-disabled defaults) with 500ms status polling up to 10 seconds - Replace 5-second stop-checker polling loop with tokio::sync::Notify for instant zero-cost wakeup (eliminates ~17,280 timer wakes per day) - Early bail during relay connection if user foregrounds the app Verified on device: single relay connected, scoped subscription active, group notification delivered with sender name and custom sound. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/android/background_sync.rs | 200 ++++++++++++++---- .../src/services/subscription_handler.rs | 23 +- 2 files changed, 173 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/android/background_sync.rs b/src-tauri/src/android/background_sync.rs index 5d58cc07..a1cf60d7 100644 --- a/src-tauri/src/android/background_sync.rs +++ b/src-tauri/src/android/background_sync.rs @@ -53,6 +53,10 @@ pub fn is_activity_in_foreground() -> bool { /// Signal to stop the standalone sync thread static STOP_STANDALONE_SYNC: AtomicBool = AtomicBool::new(false); +/// Instant wakeup for the stop-checker task. Zero CPU cost when idle (futex-based), +/// unlike the old 5-second polling loop which woke the runtime 17,280 times/day. +static STOP_NOTIFY: std::sync::LazyLock = std::sync::LazyLock::new(|| tokio::sync::Notify::new()); + /// Whether the standalone sync thread is currently running static STANDALONE_SYNC_RUNNING: AtomicBool = AtomicBool::new(false); @@ -79,6 +83,7 @@ pub extern "C" fn Java_io_vectorapp_MainActivity_nativeOnResume( if STANDALONE_SYNC_RUNNING.load(Ordering::SeqCst) { logcat("Stopping standalone sync (activity resumed)"); STOP_STANDALONE_SYNC.store(true, Ordering::SeqCst); + STOP_NOTIFY.notify_one(); } } @@ -222,6 +227,7 @@ pub extern "C" fn Java_io_vectorapp_VectorNotificationService_nativeStopBackgrou logcat("Stopping background sync"); BACKGROUND_SYNC_ACTIVE.store(false, Ordering::SeqCst); STOP_STANDALONE_SYNC.store(true, Ordering::SeqCst); + STOP_NOTIFY.notify_one(); } /// Main loop for standalone background sync. @@ -288,45 +294,52 @@ fn run_standalone_sync_loop(data_dir: &str) { } }; - // Subscribe to MLS group messages (only useful for unencrypted accounts that can decrypt) + // Preload profiles and MLS groups — needed for display names and scoped subscriptions. + // Skip for encrypted accounts (can't read from encrypted DB). + if can_decrypt { + preload_profiles_into_state().await; + preload_mls_groups_into_state().await; + } + + // Subscribe to MLS group messages scoped to OUR groups only (via 'h' tag). + // Without scoping, the relay sends ALL MLS traffic network-wide — massive battery waste. + // Scoped after preload so group IDs are available from the DB. let mls_sub_id = if can_decrypt { - let mls_filter = Filter::new() - .kind(Kind::MlsGroupMessage) - .limit(0); - match client.subscribe(mls_filter, None).await { - Ok(output) => { - logcat("Live MLS group message subscription active"); - Some(output.val) - } - Err(e) => { - logcat(&format!("Failed to subscribe to MLS messages: {:?}", e)); - None + let group_ids: Vec = match crate::db::load_mls_groups().await { + Ok(groups) => groups.into_iter() + .filter(|g| !g.evicted) + .map(|g| g.group_id) + .collect(), + Err(_) => vec![], + }; + if group_ids.is_empty() { + logcat("No MLS groups found, skipping MLS subscription"); + None + } else { + logcat(&format!("Subscribing to MLS messages for {} groups", group_ids.len())); + let mls_filter = Filter::new() + .kind(Kind::MlsGroupMessage) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), group_ids) + .limit(0); + match client.subscribe(mls_filter, None).await { + Ok(output) => Some(output.val), + Err(e) => { + logcat(&format!("Failed to subscribe to MLS messages: {:?}", e)); + None + } } } } else { None }; - // Preload profiles AFTER subscribe — runs while relay TCP/TLS handshakes complete. - // Profiles are only needed when a notification arrives (to resolve display names). - // Skip for encrypted accounts (can't read profiles from encrypted DB). - if can_decrypt { - preload_profiles_into_state().await; - preload_mls_groups_into_state().await; - } - // Spawn a stop-checker task that disconnects the client when stop is signaled. - // This ensures handle_notifications() returns even if no events arrive. + // Uses Notify for instant zero-cost wakeup instead of polling. let client_for_stop = client.clone(); tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - if STOP_STANDALONE_SYNC.load(Ordering::SeqCst) { - logcat("Stop signal received, disconnecting client..."); - client_for_stop.disconnect().await; - break; - } - } + STOP_NOTIFY.notified().await; + logcat("Stop signal received, disconnecting client..."); + client_for_stop.disconnect().await; }); // Track seen event IDs to deduplicate across relays @@ -402,6 +415,116 @@ fn run_standalone_sync_loop(data_dir: &str) { }); } +/// Connect the background client to a SINGLE relay for battery efficiency. +/// Tries each candidate relay in order until one connects successfully. +/// +/// Relay priority: user's custom relays first, then non-disabled defaults. +/// Connecting to one relay instead of 4-5 dramatically reduces mobile radio wakeups +/// and battery drain — background only needs one relay for push notifications. +async fn bg_connect_single_relay(client: &Client, data_dir: &str) -> Result<(), String> { + // Build the candidate list: user's custom relays first, then defaults + let mut candidates: Vec = Vec::new(); + + // Read user's relay config from DB (custom relays + disabled defaults) + let npub_dir = std::fs::read_dir(data_dir) + .ok() + .and_then(|entries| { + entries.flatten().find(|e| { + e.file_name().to_str().map_or(false, |n| n.starts_with("npub1")) + }) + }) + .map(|e| e.path()); + + let (custom_relays, disabled_defaults) = if let Some(ref dir) = npub_dir { + let db_path = dir.join("vector.db"); + if let Ok(conn) = rusqlite::Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + let custom: Vec = conn.query_row( + "SELECT value FROM settings WHERE key = 'custom_relays'", + [], + |row| row.get::<_, String>(0), + ).ok() + .and_then(|json| serde_json::from_str::>(&json).ok()) + .map(|arr| arr.iter().filter_map(|v| { + // Custom relays are objects with "url" and optionally "enabled" fields + let url = v.get("url").and_then(|u| u.as_str())?; + let enabled = v.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true); + if enabled { Some(url.to_string()) } else { None } + }).collect()) + .unwrap_or_default(); + + let disabled: Vec = conn.query_row( + "SELECT value FROM settings WHERE key = 'disabled_default_relays'", + [], + |row| row.get::<_, String>(0), + ).ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + + (custom, disabled) + } else { + (vec![], vec![]) + } + } else { + (vec![], vec![]) + }; + + // Custom relays first (user's preference) + candidates.extend(custom_relays); + + // Then non-disabled defaults + for url in DEFAULT_RELAYS { + let normalized = url.to_lowercase(); + let is_disabled = disabled_defaults.iter().any(|d| d.eq_ignore_ascii_case(&normalized)); + if !is_disabled && !candidates.iter().any(|c| c.eq_ignore_ascii_case(url)) { + candidates.push(url.to_string()); + } + } + + // Fallback: if everything is disabled/empty, use all defaults + if candidates.is_empty() { + candidates.extend(DEFAULT_RELAYS.iter().map(|s| s.to_string())); + } + + // Try each relay until one connects successfully + for url in &candidates { + logcat(&format!("Background: trying relay {}", url)); + if let Err(e) = client.add_relay(url.as_str()).await { + logcat(&format!("Failed to add relay {}: {:?}", url, e)); + continue; + } + client.connect().await; + + // Poll for connection (500ms intervals, up to 10 seconds). + // Mobile TLS handshakes can take 3-5s; a single fixed sleep misses slow relays. + let mut connected = false; + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + // Bail if user foregrounded the app during connection + if STOP_STANDALONE_SYNC.load(Ordering::SeqCst) { + return Err("Stop signal during relay connection".to_string()); + } + if client.relays().await.values().any(|r| matches!(r.status(), RelayStatus::Connected)) { + connected = true; + break; + } + } + + if connected { + logcat(&format!("Background: connected to {}", url)); + return Ok(()); + } + + // Didn't connect — remove and try next + logcat(&format!("Background: {} failed to connect, trying next", url)); + client.remove_relay(url.as_str()).await; + } + + Err("Failed to connect to any relay".to_string()) +} + /// Bootstrap a standalone Nostr client from stored keys in the database. /// Returns (client, public_key, can_decrypt). /// When local encryption is enabled, returns a read-only client (no signer) that can @@ -480,15 +603,7 @@ async fn bootstrap_client(data_dir: &str) -> Result<(Client, PublicKey, bool, Op &npub_name[..20.min(npub_name.len())])); let client = Client::builder().build(); - - for relay_url in DEFAULT_RELAYS { - if let Err(e) = client.add_relay(*relay_url).await { - logcat(&format!("Failed to add relay {}: {:?}", relay_url, e)); - } - } - - logcat(&format!("Connecting to {} relays...", DEFAULT_RELAYS.len())); - client.connect().await; + bg_connect_single_relay(&client, data_dir).await?; Ok((client, my_public_key, false, None)) } else { @@ -518,14 +633,7 @@ async fn bootstrap_client(data_dir: &str) -> Result<(Client, PublicKey, bool, Op .signer(crate::guarded_key::GuardedSigner::new(public_key_for_signer)) .build(); - for relay_url in DEFAULT_RELAYS { - if let Err(e) = client.add_relay(*relay_url).await { - logcat(&format!("Failed to add relay {}: {:?}", relay_url, e)); - } - } - - logcat(&format!("Connecting to {} relays...", DEFAULT_RELAYS.len())); - client.connect().await; + bg_connect_single_relay(&client, data_dir).await?; Ok((client, my_public_key, true, Some(keys))) } diff --git a/src-tauri/src/services/subscription_handler.rs b/src-tauri/src/services/subscription_handler.rs index c9ffb632..9859378e 100644 --- a/src-tauri/src/services/subscription_handler.rs +++ b/src-tauri/src/services/subscription_handler.rs @@ -690,10 +690,25 @@ pub(crate) async fn start_subscriptions() -> Result { .kind(Kind::GiftWrap) .limit(0); - // Live MLS group wrappers (Kind::MlsGroupMessage). Broad subscribe; we'll filter by membership in handler. - let mls_msg_filter = Filter::new() - .kind(Kind::MlsGroupMessage) - .limit(0); + // Live MLS group wrappers scoped to our groups via 'h' tag. + // Falls back to broad subscribe if group loading fails. + let group_ids: Vec = crate::db::load_mls_groups().await + .unwrap_or_default() + .into_iter() + .filter(|g| !g.evicted) + .map(|g| g.group_id) + .collect(); + + let mls_msg_filter = if group_ids.is_empty() { + Filter::new() + .kind(Kind::MlsGroupMessage) + .limit(0) + } else { + Filter::new() + .kind(Kind::MlsGroupMessage) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), group_ids) + .limit(0) + }; // Subscribe to both filters let gift_sub_id = match client.subscribe(giftwrap_filter, None).await { From c073a5372340ac7eda53f73c05ad79588e842ee7 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 10:54:43 +0000 Subject: [PATCH 014/417] fix(android): resolve dead client on foreground resume and dynamic MLS subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the background service set NOSTR_CLIENT before the Activity created, foregrounding the app inherited a disconnected client with a single relay and partial state (only MLS groups + notification profiles). This caused missing DMs, missing profiles, and empty UI on resume. Fixes: - Clear stale relay pool and partial STATE in login_from_stored_key guard so the frontend always does a full boot with all relays and complete data - Add FULL_SESSION_INITIALIZED flag to prevent debug hot-reload from using standalone sync's partial state (only MLS groups, no DMs) - Unified MLS subscription via refresh_mls_subscription(): single sub updated in place on group join, leave, or create — never accumulates, respects relay subscription limits - Foreground MLS subscription properly skips when no groups exist (never falls back to subscribing to the entire network's MLS traffic) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/account.rs | 42 ++++++++++- src-tauri/src/commands/mls.rs | 18 ++++- .../src/services/subscription_handler.rs | 70 ++++++++++++------- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index def94426..aa891386 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -11,9 +11,14 @@ use nostr_sdk::prelude::*; use tauri::{AppHandle, Emitter, Manager, Runtime}; use zeroize::Zeroize; +use std::sync::atomic::AtomicBool; use crate::{STATE, TAURI_APP, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, MNEMONIC_SEED, PENDING_NSEC, PENDING_INVITE, active_trusted_relays}; use crate::{Profile, account_manager, db, crypto, commands}; +/// Set to true after a full foreground login+sync flow completes. +/// Prevents debug_hot_reload_sync from using partial state preloaded by standalone background sync. +static FULL_SESSION_INITIALIZED: AtomicBool = AtomicBool::new(false); + // ============================================================================ // Types // ============================================================================ @@ -54,10 +59,14 @@ pub async fn debug_hot_reload_sync() -> Result { // Get the full state let state = STATE.lock().await; - // Verify we have meaningful state (not just an empty initialized state) + // Verify we have state from a full app session, not partial state from + // standalone background sync (which only preloads MLS groups + notification profiles). if state.profiles.is_empty() && state.chats.is_empty() { return Err("Backend state is empty - perform normal login".to_string()); } + if !FULL_SESSION_INITIALIZED.load(std::sync::atomic::Ordering::Acquire) { + return Err("State is from background sync, not a full session - perform normal login".to_string()); + } // Return the full state for the frontend to hydrate println!("[Debug Hot-Reload] Sending cached state to frontend ({} profiles, {} chats)", @@ -158,6 +167,7 @@ pub async fn login(mut import_key: String) -> Result { } } + FULL_SESSION_INITIALIZED.store(true, std::sync::atomic::Ordering::Release); Ok(LoginResult { public: npub }) } @@ -277,6 +287,7 @@ pub async fn create_account() -> Result { // This prevents creating "dead accounts" if user quits before setting a PIN account_manager::set_pending_account(npub.clone())?; + FULL_SESSION_INITIALIZED.store(true, std::sync::atomic::Ordering::Release); Ok(LoginResult { public: npub }) } @@ -446,8 +457,32 @@ pub async fn decrypt(ciphertext: String, password: Option) -> Result) -> Result { - // If already logged in, just return the npub - if let Some(_client) = NOSTR_CLIENT.get() { + // If already logged in (e.g. standalone background sync set NOSTR_CLIENT before + // the Activity created), clear stale state so the foreground rebuilds everything fresh. + // The client's signer (GuardedSigner) and MY_SECRET_KEY are still valid. + if let Some(client) = NOSTR_CLIENT.get() { + // Remove background sync's relay(s) — they may be disconnected and incomplete. + // The frontend's connect() call will add all proper relays and reconnect. + let stale_relays: Vec = client.relays().await + .keys().map(|u| u.to_string()).collect(); + for url in &stale_relays { + let _ = client.remove_relay(url.as_str()).await; + } + + // Clear STATE — standalone sync only preloads MLS groups + notification profiles, + // which is a partial subset. The debug hot-reload guard would mistake this for a + // full session state and skip login, showing only group chats and missing DMs. + // Clearing forces the frontend through the full boot flow. + { + let mut state = STATE.lock().await; + state.profiles.clear(); + state.chats.clear(); + } + + if !stale_relays.is_empty() { + println!("[Login] Cleared {} stale relay(s) and partial state from background sync", stale_relays.len()); + } + let npub = crate::MY_PUBLIC_KEY.get().ok_or("Public key not initialized")? .to_bech32() .map_err(|e| format!("Bech32 error: {}", e))?; @@ -499,6 +534,7 @@ pub async fn login_from_stored_key(password: Option) -> Result Result<(), String> { }) }) .await - .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Task join error: {}", e))??; + + // Refresh the live MLS subscription to remove the left group + crate::services::subscription_handler::refresh_mls_subscription().await; + Ok(()) } // ============================================================================ @@ -908,7 +912,7 @@ pub async fn create_mls_group( }); // Use tokio::task::spawn_blocking to run the non-Send MlsService in a blocking context - tokio::task::spawn_blocking(move || { + let group_id = tokio::task::spawn_blocking(move || { TAURI_APP.get().ok_or("App handle not initialized")?; // Use tokio runtime to run async code from blocking context @@ -931,7 +935,12 @@ pub async fn create_mls_group( }) }) .await - .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Task join error: {}", e))??; + + // Refresh the live MLS subscription to include the new group + crate::services::subscription_handler::refresh_mls_subscription().await; + + Ok(group_id) } /// Create an MLS group from a group name + member npubs (multi-device aware) @@ -1854,6 +1863,9 @@ pub async fn accept_mls_welcome(welcome_event_id_hex: String) -> Result>> = LazyLock::new(|| Mutex::new(None)); + /// Process an MLS group message event through the full pipeline. /// Shared between live subscriptions (full app) and standalone sync (background service). /// Handles: membership check, MDK decryption, rumor processing, DB persistence, @@ -677,6 +684,39 @@ pub(crate) async fn handle_mls_group_message(event: Event, my_public_key: Public } /// Start live subscriptions for GiftWraps and MLS group messages. +/// Refresh the MLS group message subscription with current group IDs from the DB. +/// Unsubscribes the old subscription (if any) and creates a new one scoped to our groups. +/// Called at boot and whenever groups change (join/leave/evict). +pub(crate) async fn refresh_mls_subscription() { + let Some(client) = NOSTR_CLIENT.get() else { return; }; + + let group_ids: Vec = crate::db::load_mls_groups().await + .unwrap_or_default() + .into_iter() + .filter(|g| !g.evicted) + .map(|g| g.group_id) + .collect(); + + let mut sub_guard = MLS_SUB_ID.lock().await; + + // Unsubscribe the old MLS subscription + if let Some(old_id) = sub_guard.take() { + client.unsubscribe(&old_id).await; + } + + // Subscribe with current group IDs (skip if no groups) + if !group_ids.is_empty() { + let filter = Filter::new() + .kind(Kind::MlsGroupMessage) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), group_ids) + .limit(0); + match client.subscribe(filter, None).await { + Ok(output) => { *sub_guard = Some(output.val); } + Err(e) => eprintln!("[MLS] Failed to subscribe: {:?}", e), + } + } +} + /// Called once after login to begin receiving real-time events. pub(crate) async fn start_subscriptions() -> Result { let client = NOSTR_CLIENT.get().expect("Nostr client not initialized"); @@ -690,35 +730,13 @@ pub(crate) async fn start_subscriptions() -> Result { .kind(Kind::GiftWrap) .limit(0); - // Live MLS group wrappers scoped to our groups via 'h' tag. - // Falls back to broad subscribe if group loading fails. - let group_ids: Vec = crate::db::load_mls_groups().await - .unwrap_or_default() - .into_iter() - .filter(|g| !g.evicted) - .map(|g| g.group_id) - .collect(); - - let mls_msg_filter = if group_ids.is_empty() { - Filter::new() - .kind(Kind::MlsGroupMessage) - .limit(0) - } else { - Filter::new() - .kind(Kind::MlsGroupMessage) - .custom_tags(SingleLetterTag::lowercase(Alphabet::H), group_ids) - .limit(0) - }; - - // Subscribe to both filters let gift_sub_id = match client.subscribe(giftwrap_filter, None).await { Ok(id) => id.val, Err(e) => return Err(e.to_string()), }; - let mls_sub_id = match client.subscribe(mls_msg_filter, None).await { - Ok(id) => id.val, - Err(e) => return Err(e.to_string()), - }; + + // Initial MLS subscription (scoped to current groups, or skipped if none) + refresh_mls_subscription().await; // Begin watching for notifications from our subscriptions match client @@ -727,7 +745,7 @@ pub(crate) async fn start_subscriptions() -> Result { if subscription_id == gift_sub_id { // Handle DMs/files/vector-specific + MLS welcomes inside giftwrap super::handle_event(*event, true).await; - } else if subscription_id == mls_sub_id { + } else if MLS_SUB_ID.lock().await.as_ref() == Some(&subscription_id) { // Handle live MLS group message via shared handler let my_pk = crate::MY_PUBLIC_KEY.get() .copied() From 6d7dd8fdb9ac5a7b8933178cbeb385241a9ee0e9 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 11:03:17 +0000 Subject: [PATCH 015/417] fix: filter blocked users from invite and create group dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocked users were showing in the "Invite Member" and "Create Group" member selection lists despite being blocked. The is_blocked property was already available on every profile object — just not checked. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.js b/src/main.js index 5c6c6c5f..97779cbf 100644 --- a/src/main.js +++ b/src/main.js @@ -10242,6 +10242,7 @@ async function openInviteMemberToGroup(chat) { const availableContacts = arrProfiles.filter(p => { if (!p || !p.id || p.id === mine) return false; if (currentMembers.includes(p.id)) return false; + if (p.is_blocked) return false; if (filter) { const name = (p.nickname || p.name || '').toLowerCase(); @@ -11942,6 +11943,7 @@ function renderCreateGroupList(filterText = '') { for (const p of sortedProfiles) { if (!p || !p.id) continue; if (p.id === mine) continue; + if (p.is_blocked) continue; // Filter by nickname/name/npub (use extracted npub if input is a profile URL) const name = p.nickname || p.name || ''; From 210f47081d4fc4771fa2e9c8f7933f21876f0a6b Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 15:12:33 +0000 Subject: [PATCH 016/417] fix: MLS message dedup on relay echo and XSS in GIF search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MLS group messages visually duplicated because the relay echo had a different wrapper event ID than what the sender tracked. Now the sent wrapper ID is registered in mls_processed_events immediately after send, so the echo is caught by existing dedup before reaching the UI. Also fix DOM text reinterpretation in showGifEmptyState() — user-typed GIF search queries were interpolated into innerHTML. Now uses textContent via DOM API (resolves GitHub code scanning alert #13). Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/mls/messaging.rs | 19 +++++++++++++++++++ src/main.js | 17 +++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/mls/messaging.rs b/src-tauri/src/mls/messaging.rs index 53941dc1..317e0c90 100644 --- a/src-tauri/src/mls/messaging.rs +++ b/src-tauri/src/mls/messaging.rs @@ -144,6 +144,25 @@ pub async fn send_mls_message(group_id: &str, rumor: nostr_sdk::UnsignedEvent, p crate::inbox_relays::send_event_first_ok(client, urls, &mls_wrapper).await }; + // Track the wrapper event ID we just sent so the relay echo is deduplicated. + // Without this, the subscription handler sees the echo as a "new" event + // (different wrapper ID from what it tracked) and emits a duplicate to the frontend. + if send_result.is_ok() { + let sent_wrapper_id = if is_typing_indicator { + // We don't track typing indicators — they're ephemeral + None + } else { + Some(mls_wrapper.id.to_hex()) + }; + if let Some(wrapper_id) = sent_wrapper_id { + let _ = crate::mls::track_mls_event_processed( + &wrapper_id, + &group_id, + mls_wrapper.created_at.as_secs(), + ); + } + } + // Update pending message based on send result if let (Some(ref pid), Some(ref real_id)) = (&pending_id, &inner_event_id) { match send_result { diff --git a/src/main.js b/src/main.js index 97779cbf..3f22f0a7 100644 --- a/src/main.js +++ b/src/main.js @@ -2549,12 +2549,17 @@ function loadGifWithFallback(gifItem, mediaUrl, gifId, gifTitle, placeholder, fo * @param {string} message - The message to display */ function showGifEmptyState(message) { - gifGrid.innerHTML = ` -
- - ${message} -
- `; + const div = document.createElement('div'); + div.className = 'gif-empty-state'; + div.style.gridColumn = '1 / -1'; + const icon = document.createElement('span'); + icon.className = 'icon icon-image'; + const text = document.createElement('span'); + text.textContent = message; + div.appendChild(icon); + div.appendChild(text); + gifGrid.innerHTML = ''; + gifGrid.appendChild(div); } /** From 0283f8fa134b3a8443aaffbee5df622c915e8aaf Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 15:15:34 +0000 Subject: [PATCH 017/417] fix: truncate long GIF search queries in empty state display Queries over 32 characters are truncated with ellipsis in the "No GIFs found for ..." message to prevent UI overflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index 3f22f0a7..794b1986 100644 --- a/src/main.js +++ b/src/main.js @@ -2314,7 +2314,8 @@ async function searchGifs(query) { gifCurrentOffset = data.results.length; gifHasMore = data.results.length >= gifPageSize; } else { - showGifEmptyState(`No GIFs found for "${query}"`); + const displayQuery = query.length > 32 ? query.slice(0, 32) + '...' : query; + showGifEmptyState(`No GIFs found for "${displayQuery}"`); gifHasMore = false; } } catch (error) { From a9a7ecb6177de50f13cac6f7dc59091c7ea03ab9 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 15:34:16 +0000 Subject: [PATCH 018/417] chore: bump version to v0.3.3 in README download links Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b98d31f..a5f2fb02 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ GitHub-Repo-Graphics-Header

- Linux - macOS - Windows - Android + Linux + macOS + Windows + Android License

From dbb18abef91b41b8be191db43812d870928133a5 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 17:03:29 +0000 Subject: [PATCH 019/417] fix(windows): preserve existing DACL when adding memory-read deny ACE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code passed null as the old_acl to SetEntriesInAclA, which replaced the entire process DACL with only our deny ACE — discarding all default allow ACEs. This made the process inaccessible to Explorer, breaking taskbar pinning, jump lists, and other shell integrations. Now reads the existing DACL via GetSecurityInfo first and merges the deny ACE into it, preserving all default access grants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/lib.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d225add2..6a50434f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,7 +178,29 @@ pub fn run() { let signature_policy: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; // MicrosoftSignedOnly = 1 SetProcessMitigationPolicy(8, signature_policy.as_ptr(), 4); - // 3. Strip PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE from Everyone + // 3. Strip PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE from Everyone. + // MUST merge into the existing DACL — passing null as old_acl would discard all + // default allow ACEs, making the process inaccessible to Explorer (breaks taskbar + // pinning, jump lists, and other shell integrations). + extern "system" { + fn GetSecurityInfo( + handle: isize, object_type: u32, info: u32, + owner: *mut *mut u8, group: *mut *mut u8, + dacl: *mut *mut u8, sacl: *mut *mut u8, + descriptor: *mut *mut u8, + ) -> u32; + } + + let mut existing_dacl: *mut u8 = std::ptr::null_mut(); + let mut security_descriptor: *mut u8 = std::ptr::null_mut(); + // SE_KERNEL_OBJECT = 6, DACL_SECURITY_INFORMATION = 4 + let got_dacl = GetSecurityInfo( + GetCurrentProcess(), 6, 4, + std::ptr::null_mut(), std::ptr::null_mut(), + &mut existing_dacl, std::ptr::null_mut(), + &mut security_descriptor, + ) == 0; + let everyone = b"Everyone\0"; let entry = ExplicitAccessA { access_permissions: 0x0010 | 0x0020 | 0x0040, // VM_READ | VM_WRITE | DUP_HANDLE @@ -193,8 +215,9 @@ pub fn run() { }, }; let mut new_dacl: *mut u8 = std::ptr::null_mut(); - if SetEntriesInAclA(1, &entry, std::ptr::null(), &mut new_dacl) == 0 && !new_dacl.is_null() { - // SE_KERNEL_OBJECT = 6, DACL_SECURITY_INFORMATION = 4 + // Merge our deny ACE into the existing DACL (preserving default allow ACEs) + let old_dacl = if got_dacl && !existing_dacl.is_null() { existing_dacl } else { std::ptr::null() }; + if SetEntriesInAclA(1, &entry, old_dacl, &mut new_dacl) == 0 && !new_dacl.is_null() { SetSecurityInfo(GetCurrentProcess(), 6, 4, std::ptr::null(), std::ptr::null(), new_dacl, std::ptr::null()); } } From f93319dcb05df5e8964e202b7571a26438ee0735 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 24 Mar 2026 18:34:52 +0000 Subject: [PATCH 020/417] fix: password encryption field styling to match nsec/seed login field The password entry field on the encryption login screen was missing the chat-input-container wrapper, causing it to look unstyled. Added the same HTML structure as the nsec/seed field with CSS overrides to prevent the header from hiding behind the input (position: relative instead of absolute, zero auto margins). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.html | 10 ++++++---- src/styles.css | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/index.html b/src/index.html index f04f3ad9..de8e85aa 100644 --- a/src/index.html +++ b/src/index.html @@ -1318,10 +1318,12 @@