Skip to content

Commit 1f306e5

Browse files
JSKittyclaude
andcommitted
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) <noreply@anthropic.com>
1 parent d78e381 commit 1f306e5

5 files changed

Lines changed: 212 additions & 35 deletions

File tree

src-tauri/gen/android/app/src/main/java/io/vectorapp/miniapp/MiniAppWebViewClient.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class MiniAppWebViewClient(
4545
*
4646
* This matches the desktop implementation for consistency.
4747
*/
48-
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'"""
48+
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'"""
4949

5050
/**
5151
* Permissions Policy that denies all sensitive APIs by default.

src-tauri/src/android/miniapp_jni.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,8 @@ pub extern "C" fn Java_io_vectorapp_miniapp_MiniAppIpc_joinRealtimeChannelNative
459459

460460
// Join the channel with mpsc event target
461461
let event_target = crate::miniapps::realtime::EventTarget::MpscSender(tx);
462-
let is_rejoin = match iroh.join_channel(topic, vec![], Some(event_target), Some(app_for_join.clone()), miniapp_id_for_join.clone()).await {
462+
let ws_targets = Some(state.realtime.ws_senders.clone());
463+
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 {
463464
Ok((rejoin, _)) => {
464465
if rejoin {
465466
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 {
12791280
rtWs.binaryType = 'arraybuffer';
12801281
rtWs.onclose = function() {{ rtWs = null; }};
12811282
rtWs.onerror = function() {{ try {{ rtWs.close(); }} catch(e) {{}} rtWs = null; }};
1283+
// Bi-directional: receive gossip data via WS (bypasses JNI starvation)
1284+
rtWs.onmessage = function(ev) {{
1285+
var fn_listener = window.__miniapp_realtime_listener;
1286+
if (fn_listener && ev.data) {{
1287+
// Data arrives as base91 string in binary frame
1288+
var bytes = new Uint8Array(ev.data);
1289+
var str = '';
1290+
for (var i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
1291+
fn_listener(new Uint8Array(b91d(str)));
1292+
}}
1293+
}};
12821294
// Detect stuck CONNECTING state (some WebViews silently block WS)
12831295
setTimeout(function() {{
12841296
if (rtWs && rtWs.readyState === 0) {{

src-tauri/src/miniapps/commands.rs

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -696,8 +696,9 @@ pub async fn miniapp_open(
696696
href: Option<String>,
697697
topic_id: Option<String>,
698698
) -> Result<(), Error> {
699+
log_info!("[WEBXDC] miniapp_open called: chat={}, msg={}", chat_id, message_id);
699700
let path = PathBuf::from(&file_path);
700-
701+
701702
// Generate unique ID from file hash
702703
let id = format!("miniapp_{:x}", md5_hash(&file_path));
703704
// For marketplace apps (empty chat/message), use the app id as the window label
@@ -724,8 +725,29 @@ pub async fn miniapp_open(
724725
}
725726
return Ok(());
726727
} else {
727-
// Overlay was closed, clean up state
728-
log_warn!("Instance exists but overlay closed, cleaning up: {}", existing_label);
728+
// Overlay was closed but state was never cleaned up.
729+
// Do full teardown: realtime channel, session peers, instance.
730+
log_warn!("Instance exists but overlay closed, full cleanup: {}", existing_label);
731+
732+
let channel_state = state.remove_realtime_channel(&existing_label).await;
733+
if let Some(channel) = channel_state {
734+
let topic_encoded = super::realtime::encode_topic_id(&channel.topic);
735+
if let Ok(iroh) = state.realtime.get_or_init().await {
736+
if let Err(e) = iroh.leave_channel(channel.topic, &existing_label).await {
737+
log_warn!("[WEBXDC] Stale cleanup: leave_channel failed: {}", e);
738+
}
739+
}
740+
if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() {
741+
if let Ok(my_npub) = my_pk.to_bech32() {
742+
state.remove_session_peer(&channel.topic, &my_npub).await;
743+
}
744+
}
745+
let chat_id_clone = chat_id.clone();
746+
tokio::spawn(async move {
747+
crate::commands::realtime::send_webxdc_peer_left(chat_id_clone, topic_encoded).await;
748+
});
749+
}
750+
state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&existing_label);
729751
state.remove_instance(&existing_label).await;
730752
}
731753
}
@@ -833,11 +855,32 @@ pub async fn miniapp_open(
833855
Err(e) => { log_warn!("[WEBXDC] Preconnect: Iroh init failed: {e}"); return; }
834856
};
835857

836-
// Create gossip channel with NO event target. Incoming data is BUFFERED
837-
// (not dropped) until joinRealtimeChannel sets the target and flushes.
838-
// This lets us form the gossip mesh NOW so peers are connected before
839-
// the game starts its election/handshake.
840-
if let Err(e) = iroh.join_channel(topic, vec![], None, Some(app_pc.clone()), label_pc.clone()).await {
858+
// Collect any cached peer addresses (from advertisements that arrived
859+
// before we opened) + persisted peers from the DB to use as bootstrap.
860+
let mut bootstrap_peers: Vec<iroh::EndpointAddr> = Vec::new();
861+
862+
// Cached from recent Nostr advertisements
863+
let cached = state.take_peer_addrs(&topic).await;
864+
bootstrap_peers.extend(cached);
865+
866+
// Persisted from DB
867+
let my_npub = crate::MY_PUBLIC_KEY.get()
868+
.and_then(|pk| nostr_sdk::prelude::ToBech32::to_bech32(pk).ok())
869+
.unwrap_or_default();
870+
if let Ok(records) = crate::db::get_active_peer_advertisements(&topic_encoded, &my_npub) {
871+
for record in &records {
872+
if let Ok(addr) = super::realtime::decode_node_addr(&record.node_addr_encoded) {
873+
bootstrap_peers.push(addr);
874+
}
875+
}
876+
}
877+
878+
log_info!("[WEBXDC] Preconnect: joining with {} bootstrap peers", bootstrap_peers.len());
879+
880+
// Create gossip channel with bootstrap peers and NO event target.
881+
// Incoming data is BUFFERED (not dropped) until joinRealtimeChannel
882+
// sets the target and flushes.
883+
if let Err(e) = iroh.join_channel(topic, bootstrap_peers, None, Some(app_pc.clone()), label_pc.clone(), None).await {
841884
log_warn!("[WEBXDC] Preconnect: join_channel failed: {e}");
842885
return;
843886
}
@@ -1163,6 +1206,36 @@ pub async fn miniapp_close(
11631206
}
11641207
}
11651208

1209+
// Full teardown: remove channel state, leave gossip, clean up
1210+
// (Desktop does this in WindowEvent::Destroyed, but Android has no
1211+
// Tauri window, so we must do it here explicitly)
1212+
let channel_state = state.remove_realtime_channel(&label).await;
1213+
if let Some(channel) = channel_state {
1214+
let topic_encoded = super::realtime::encode_topic_id(&channel.topic);
1215+
1216+
if let Ok(iroh) = state.realtime.get_or_init().await {
1217+
if let Err(e) = iroh.leave_channel(channel.topic, &label).await {
1218+
log_warn!("[WEBXDC] Failed to leave channel on close: {}", e);
1219+
}
1220+
}
1221+
1222+
// Remove ourselves from session peers
1223+
if let Some(my_pk) = crate::MY_PUBLIC_KEY.get() {
1224+
if let Ok(my_npub) = my_pk.to_bech32() {
1225+
state.remove_session_peer(&channel.topic, &my_npub).await;
1226+
}
1227+
}
1228+
1229+
// Send peer-left via Nostr
1230+
let chat_id_clone = chat_id.clone();
1231+
tokio::spawn(async move {
1232+
crate::commands::realtime::send_webxdc_peer_left(chat_id_clone, topic_encoded).await;
1233+
});
1234+
}
1235+
1236+
// Clean up WS sender
1237+
state.realtime.ws_senders.write().unwrap_or_else(|e| e.into_inner()).remove(&label);
1238+
11661239
state.remove_instance(&label).await;
11671240
}
11681241

@@ -1306,7 +1379,8 @@ pub async fn miniapp_join_realtime_channel(
13061379

13071380
// Create the gossip channel WITH the event target — no data can be dropped
13081381
let event_target = EventTarget::TauriChannel(channel);
1309-
let (is_rejoin, _) = iroh.join_channel(topic, vec![], Some(event_target), Some(app.clone()), label.to_string()).await
1382+
let ws_targets = Some(state.realtime.ws_senders.clone());
1383+
let (is_rejoin, _) = iroh.join_channel(topic, vec![], Some(event_target), Some(app.clone()), label.to_string(), ws_targets).await
13101384
.map_err(|e| Error::RealtimeError(e.to_string()))?;
13111385

13121386
let topic_encoded_clone = topic_encoded.clone();

src-tauri/src/miniapps/realtime.rs

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ impl IrohState {
254254
event_target: Option<EventTarget>,
255255
app_handle: Option<AppHandle>,
256256
label: String,
257+
ws_event_targets: Option<Arc<std::sync::RwLock<HashMap<String, tokio::sync::mpsc::Sender<Vec<u8>>>>>>,
257258
) -> Result<(bool, Option<oneshot::Receiver<()>>)> {
258259
let mut channels = self.channels.write().await;
259260

@@ -265,6 +266,15 @@ impl IrohState {
265266
let mut state = channel_state.event_target.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() });
266267
state.set_target(target); // Flushes buffered events from preconnect phase
267268
}
269+
// Wire up WS sender for bi-directional receive (if WS is connected)
270+
if let Some(ref senders) = ws_event_targets {
271+
let map = senders.read().unwrap_or_else(|e| e.into_inner());
272+
if let Some(ws_tx) = map.get(&label) {
273+
let mut state = channel_state.event_target.write().unwrap_or_else(|e| e.into_inner());
274+
state.set_ws_sender(ws_tx.clone());
275+
log_info!("[WEBXDC] RT WS bi-directional enabled for: {label}");
276+
}
277+
}
268278
return Ok((true, None));
269279
}
270280

@@ -276,7 +286,20 @@ impl IrohState {
276286
peer_ids.len()
277287
);
278288

279-
// Connect to peers so gossip can discover them
289+
// DON'T manually connect + handle_connection here — that creates
290+
// connections BEFORE the topic subscription exists, causing a race
291+
// where messages arrive for an unregistered topic and get lost.
292+
// Instead, connect AFTER subscribing, so the gossip actor has the
293+
// topic registered when the connection delivers messages.
294+
295+
let (join_tx, join_rx) = oneshot::channel();
296+
297+
let gossip_topic = self
298+
.gossip
299+
.subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids))
300+
.await?;
301+
302+
// NOW connect — topic subscription is registered, safe to receive
280303
for peer_addr in &peers {
281304
if !peer_addr.addrs.is_empty() {
282305
let addr = peer_addr.clone();
@@ -294,19 +317,22 @@ impl IrohState {
294317
});
295318
}
296319
}
297-
298-
let (join_tx, join_rx) = oneshot::channel();
299-
300-
let gossip_topic = self
301-
.gossip
302-
.subscribe_with_opts(topic, JoinOptions::with_bootstrap(peer_ids))
303-
.await?;
304320
let (gossip_sender, gossip_receiver) = gossip_topic.split();
305321

306322
// Create shared event target for the subscribe loop (buffers events if target is None)
307323
let shared_event_target: SharedEventTarget = Arc::new(std::sync::RwLock::new(EventTargetState::new(event_target)));
308324
let shared_target_clone = shared_event_target.clone();
309325

326+
// Wire up WS sender for bi-directional receive (if WS is connected)
327+
if let Some(ref senders) = ws_event_targets {
328+
let map = senders.read().unwrap_or_else(|e| e.into_inner());
329+
if let Some(ws_tx) = map.get(&label) {
330+
let mut state = shared_event_target.write().unwrap_or_else(|e| e.into_inner());
331+
state.set_ws_sender(ws_tx.clone());
332+
log_info!("[WEBXDC] RT WS bi-directional enabled for: {label}");
333+
}
334+
}
335+
310336
// Create shared peer count
311337
let shared_peer_count: SharedPeerCount = Arc::new(AtomicUsize::new(0));
312338
let peer_count_clone = shared_peer_count.clone();
@@ -391,11 +417,12 @@ impl IrohState {
391417

392418
log_trace!("[WEBXDC] add_peer: Connecting to peer {}", peer_addr.id);
393419

394-
// Connect to the peer and hand the connection to gossip
420+
// Connect and hand to gossip, then join_peers.
421+
// Topic subscription already exists (channel is in the map),
422+
// so the connection won't race with topic registration.
395423
let conn = self.endpoint.connect(peer_addr, GOSSIP_ALPN).await?;
396424
self.gossip.handle_connection(conn).await?;
397425

398-
// Join the peer to the existing gossip topic
399426
let channels = self.channels.read().await;
400427
if let Some(channel_state) = channels.get(topic) {
401428
channel_state.sender.join_peers(vec![peer.id]).await?;
@@ -459,6 +486,9 @@ impl IrohState {
459486
// 1. Remove fast-path SendHandle (drops its GossipSender clone)
460487
self.send_handles.write().unwrap_or_else(|e| { log_error!("[WEBXDC] RwLock poisoned — recovering"); e.into_inner() }).remove(label);
461488

489+
// Remove WS sender for this label (ws_senders is on RealtimeManager,
490+
// but we're on IrohState — caller handles this separately)
491+
462492
if let Some(channel) = self.channels.write().await.remove(&topic) {
463493
// 2. Drop the ChannelState's sender explicitly (don't wait for implicit drop)
464494
drop(channel.sender);
@@ -467,7 +497,7 @@ impl IrohState {
467497
channel.subscribe_loop.abort();
468498
let _ = channel.subscribe_loop.await;
469499

470-
// 4. Small yield to let the runtime fully clean up dropped tasks
500+
// 4. Yield to let the gossip actor process the quit
471501
tokio::task::yield_now().await;
472502

473503
log_info!("Left realtime channel {:?}", topic);
@@ -515,19 +545,42 @@ pub enum EventTarget {
515545
pub(crate) struct EventTargetState {
516546
target: Option<EventTarget>,
517547
buffer: Vec<RealtimeEvent>,
548+
/// Optional WebSocket sender for bi-directional WS (bypasses JNI on Android).
549+
/// When set, Data events are sent directly through WS instead of the normal target.
550+
ws_sender: Option<tokio::sync::mpsc::Sender<Vec<u8>>>,
518551
}
519552

520553
impl EventTargetState {
521554
fn new(target: Option<EventTarget>) -> Self {
522-
Self { target, buffer: Vec::new() }
555+
Self { target, buffer: Vec::new(), ws_sender: None }
523556
}
524557

525-
/// Send an event, buffering if no target is set yet
558+
/// Register a WS sender for bi-directional receive. Data events bypass
559+
/// the normal target (JNI on Android) and go straight through WebSocket.
560+
pub fn set_ws_sender(&mut self, sender: tokio::sync::mpsc::Sender<Vec<u8>>) {
561+
self.ws_sender = Some(sender);
562+
}
563+
564+
pub fn clear_ws_sender(&mut self) {
565+
self.ws_sender = None;
566+
}
567+
568+
/// Send an event, buffering if no target is set yet.
569+
/// Data events are routed through WebSocket when available (bypasses JNI on Android).
526570
fn send(&mut self, event: RealtimeEvent) -> bool {
571+
// If WS sender is available and this is a Data event, send via WS directly.
572+
// This bypasses the JNI/evaluateJavascript path that gets starved by WASM.
573+
if let Some(ref ws_tx) = self.ws_sender {
574+
if let RealtimeEvent::Data(ref b91_data) = event {
575+
// Send raw base91 string as binary WS frame
576+
let _ = ws_tx.try_send(b91_data.as_bytes().to_vec());
577+
return true;
578+
}
579+
}
580+
527581
if let Some(ref target) = self.target {
528582
Self::deliver(target, event)
529583
} else {
530-
// Buffer up to 256 events (prevents unbounded memory if target is never set)
531584
if self.buffer.len() < 256 {
532585
self.buffer.push(event);
533586
}
@@ -764,6 +817,9 @@ pub struct RealtimeManager {
764817
/// Fast-path send handles — owned here so the WS server can start
765818
/// before IrohState exists (critical for Android JNI timing).
766819
send_handles: Arc<std::sync::RwLock<HashMap<String, SendHandle>>>,
820+
/// Map of window_label → WS sender for bi-directional receive.
821+
/// WS handler registers sender on connect, join_channel wires it into the event target.
822+
pub(crate) ws_senders: Arc<std::sync::RwLock<HashMap<String, tokio::sync::mpsc::Sender<Vec<u8>>>>>,
767823
}
768824

769825
impl RealtimeManager {
@@ -773,6 +829,7 @@ impl RealtimeManager {
773829
relay_url,
774830
ws_info: std::sync::OnceLock::new(),
775831
send_handles: Arc::new(std::sync::RwLock::new(HashMap::new())),
832+
ws_senders: Arc::new(std::sync::RwLock::new(HashMap::new())),
776833
}
777834
}
778835

@@ -831,6 +888,7 @@ impl RealtimeManager {
831888

832889
// Spawn accept loop on the MAIN Tauri runtime (survives JNI temp runtime)
833890
let send_handles = self.send_handles.clone();
891+
let ws_senders = self.ws_senders.clone();
834892
tauri::async_runtime::spawn(async move {
835893
// Convert std listener to tokio listener on the main runtime
836894
let listener = match tokio::net::TcpListener::from_std(std_listener) {
@@ -840,7 +898,7 @@ impl RealtimeManager {
840898
return;
841899
}
842900
};
843-
super::rt_ws::run_accept_loop(listener, token, send_handles).await;
901+
super::rt_ws::run_accept_loop(listener, token, send_handles, ws_senders).await;
844902
});
845903
}
846904

0 commit comments

Comments
 (0)