cursor presence and real-time doc sync

Orual 215f4513 a7450e2e

+852 -95
-1
Cargo.lock
··· 11563 11563 "http", 11564 11564 "iroh", 11565 11565 "iroh-gossip", 11566 - "iroh-quinn", 11567 11566 "iroh-tickets", 11568 11567 "jacquard", 11569 11568 "jacquard-common",
+1 -1
crates/weaver-app/Cargo.toml
··· 80 80 chrono = { version = "0.4", features = ["wasmbind"] } 81 81 wasm-bindgen = "0.2" 82 82 wasm-bindgen-futures = "0.4" 83 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions"] } 83 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList"] } 84 84 js-sys = "0.3" 85 85 gloo-storage = "0.3" 86 86 gloo-timers = "0.3"
+6
crates/weaver-app/assets/styling/editor.css
··· 1188 1188 pointer-events: none; 1189 1189 } 1190 1190 1191 + .remote-selection { 1192 + position: absolute; 1193 + pointer-events: none; 1194 + border-radius: 2px; 1195 + } 1196 + 1191 1197 .remote-cursor-caret { 1192 1198 width: 2px; 1193 1199 height: var(--cursor-height, 18px);
+30 -1
crates/weaver-app/src/collab_context.rs
··· 9 9 use std::sync::Arc; 10 10 use weaver_common::transport::CollabNode; 11 11 12 + /// Debug state for the collab session, displayed in editor debug panel. 13 + #[derive(Clone, Default)] 14 + pub struct CollabDebugState { 15 + /// Our node ID 16 + pub node_id: Option<String>, 17 + /// Our relay URL 18 + pub relay_url: Option<String>, 19 + /// URI of our published session record 20 + pub session_record_uri: Option<String>, 21 + /// Number of discovered peers 22 + pub discovered_peers: usize, 23 + /// Number of connected peers 24 + pub connected_peers: usize, 25 + /// Whether we've joined the gossip swarm 26 + pub is_joined: bool, 27 + /// Last error message 28 + pub last_error: Option<String>, 29 + } 30 + 12 31 /// Context state for the collaboration node. 13 32 /// 14 33 /// This is provided as a Dioxus context and can be accessed by editor components ··· 39 58 #[component] 40 59 pub fn CollabProvider(children: Element) -> Element { 41 60 let mut collab_ctx = use_signal(CollabContext::default); 61 + let debug_state = use_signal(CollabDebugState::default); 42 62 43 63 // Spawn the CollabNode on mount 44 64 let _spawn_result = use_resource(move || async move { ··· 62 82 } 63 83 }); 64 84 65 - // Provide the context 85 + // Provide the contexts 66 86 use_context_provider(|| collab_ctx); 87 + use_context_provider(|| debug_state); 67 88 68 89 rsx! { {children} } 69 90 } ··· 74 95 pub fn CollabProvider(children: Element) -> Element { 75 96 // On server/native, provide an empty context (collab happens in browser) 76 97 let collab_ctx = use_signal(CollabContext::default); 98 + let debug_state = use_signal(CollabDebugState::default); 77 99 use_context_provider(|| collab_ctx); 100 + use_context_provider(|| debug_state); 78 101 rsx! { {children} } 79 102 } 80 103 ··· 91 114 let ctx = use_context::<Signal<CollabContext>>(); 92 115 ctx.read().node.is_some() 93 116 } 117 + 118 + /// Hook to get the collab debug state signal. 119 + /// Returns None if called outside CollabProvider. 120 + pub fn try_use_collab_debug() -> Option<Signal<CollabDebugState>> { 121 + try_use_context::<Signal<CollabDebugState>>() 122 + }
+81 -19
crates/weaver-app/src/components/editor/component.rs
··· 561 561 let mut doc_for_dom = document.clone(); 562 562 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 563 563 use_effect(move || { 564 - tracing::debug!("DOM update effect triggered"); 565 - 566 - tracing::debug!( 567 - composition_active = doc_for_dom.composition.read().is_some(), 568 - cursor = doc_for_dom.cursor.read().offset, 569 - "DOM update: checking state" 570 - ); 564 + // tracing::debug!( 565 + // composition_active = doc_for_dom.composition.read().is_some(), 566 + // cursor = doc_for_dom.cursor.read().offset, 567 + // "DOM update: checking state" 568 + // ); 571 569 572 570 // Skip DOM updates during IME composition - browser controls the preview 573 571 if doc_for_dom.composition.read().is_some() { ··· 575 573 return; 576 574 } 577 575 578 - tracing::debug!( 576 + tracing::trace!( 579 577 cursor = doc_for_dom.cursor.read().offset, 580 578 len = doc_for_dom.len_chars(), 581 579 "DOM update proceeding (not in composition)" ··· 1087 1085 onselect: { 1088 1086 let mut doc = document.clone(); 1089 1087 move |_evt| { 1090 - tracing::debug!("onselect fired"); 1088 + tracing::trace!("onselect fired"); 1091 1089 let paras = cached_paragraphs(); 1092 1090 sync_cursor_from_dom(&mut doc, editor_id, &paras); 1093 1091 let spans = syntax_spans(); ··· 1105 1103 onselectstart: { 1106 1104 let mut doc = document.clone(); 1107 1105 move |_evt| { 1108 - tracing::debug!("onselectstart fired"); 1106 + tracing::trace!("onselectstart fired"); 1109 1107 let paras = cached_paragraphs(); 1110 1108 sync_cursor_from_dom(&mut doc, editor_id, &paras); 1111 1109 let spans = syntax_spans(); ··· 1123 1121 onselectionchange: { 1124 1122 let mut doc = document.clone(); 1125 1123 move |_evt| { 1126 - tracing::debug!("onselectionchange fired"); 1124 + tracing::trace!("onselectionchange fired"); 1127 1125 let paras = cached_paragraphs(); 1128 1126 sync_cursor_from_dom(&mut doc, editor_id, &paras); 1129 1127 let spans = syntax_spans(); ··· 1141 1139 onclick: { 1142 1140 let mut doc = document.clone(); 1143 1141 move |evt| { 1144 - tracing::debug!("onclick fired"); 1142 + tracing::trace!("onclick fired"); 1145 1143 let paras = cached_paragraphs(); 1146 1144 1147 1145 // Check if click target is a math-clickable element ··· 1255 1253 let mut doc = document.clone(); 1256 1254 move |evt: CompositionEvent| { 1257 1255 let data = evt.data().data(); 1258 - tracing::debug!( 1256 + tracing::trace!( 1259 1257 data = %data, 1260 1258 "compositionstart" 1261 1259 ); ··· 1264 1262 if let Some(sel) = sel { 1265 1263 let (start, end) = 1266 1264 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1267 - tracing::debug!( 1265 + tracing::trace!( 1268 1266 start, 1269 1267 end, 1270 1268 "compositionstart: deleting selection" ··· 1274 1272 } 1275 1273 1276 1274 let cursor_offset = doc.cursor.read().offset; 1277 - tracing::debug!( 1275 + tracing::trace!( 1278 1276 cursor = cursor_offset, 1279 1277 "compositionstart: setting composition state" 1280 1278 ); ··· 1289 1287 let mut doc = document.clone(); 1290 1288 move |evt: CompositionEvent| { 1291 1289 let data = evt.data().data(); 1292 - tracing::debug!( 1290 + tracing::trace!( 1293 1291 data = %data, 1294 1292 "compositionupdate" 1295 1293 ); ··· 1306 1304 let mut doc = document.clone(); 1307 1305 move |evt: CompositionEvent| { 1308 1306 let final_text = evt.data().data(); 1309 - tracing::debug!( 1307 + tracing::trace!( 1310 1308 data = %final_text, 1311 1309 "compositionend" 1312 1310 ); ··· 1354 1352 } 1355 1353 div { class: "editor-debug", 1356 1354 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 1355 + // Collab debug info 1356 + { 1357 + if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { 1358 + let ds = debug_state.read(); 1359 + rsx! { 1360 + div { class: "collab-debug", 1361 + if let Some(ref node_id) = ds.node_id { 1362 + span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } 1363 + } 1364 + if ds.is_joined { 1365 + span { class: "joined", "✓ Joined" } 1366 + } 1367 + span { "Peers: {ds.discovered_peers}" } 1368 + if let Some(ref err) = ds.last_error { 1369 + span { class: "error", title: "{err}", "⚠" } 1370 + } 1371 + } 1372 + } 1373 + } else { 1374 + rsx! {} 1375 + } 1376 + }, 1357 1377 ReportButton { 1358 1378 email: "editor-bugs@weaver.sh".to_string(), 1359 1379 editor_id: "markdown-editor".to_string(), ··· 1516 1536 render_cache: Signal<render::RenderCache>, 1517 1537 ) -> Element { 1518 1538 let presence_read = presence.read(); 1539 + let cursor_count = presence_read.len(); 1519 1540 let cursors: Vec<_> = presence_read 1520 1541 .cursors() 1521 1542 .map(|(c, cur)| (c.display_name.clone(), c.color, cur.position, cur.selection)) 1522 1543 .collect(); 1544 + 1545 + if cursor_count > 0 { 1546 + tracing::debug!( 1547 + "RemoteCursors: {} collaborators, {} with cursors", 1548 + cursor_count, 1549 + cursors.len() 1550 + ); 1551 + } 1523 1552 1524 1553 if cursors.is_empty() { 1525 1554 return rsx! {}; ··· 1558 1587 color: u32, 1559 1588 offset_map: Vec<super::offset_map::OffsetMapping>, 1560 1589 ) -> Element { 1561 - use super::cursor::{get_cursor_rect_relative, CursorRect}; 1590 + use super::cursor::{get_cursor_rect_relative, get_selection_rects_relative}; 1562 1591 1563 - // Convert RGBA u32 to CSS color 1592 + // Convert RGBA u32 to CSS color (fully opaque for cursor) 1564 1593 let r = (color >> 24) & 0xFF; 1565 1594 let g = (color >> 16) & 0xFF; 1566 1595 let b = (color >> 8) & 0xFF; 1567 1596 let a = (color & 0xFF) as f32 / 255.0; 1568 1597 let color_css = format!("rgba({}, {}, {}, {})", r, g, b, a); 1598 + // Semi-transparent version for selection highlight 1599 + let selection_color_css = format!("rgba({}, {}, {}, 0.25)", r, g, b); 1569 1600 1570 1601 // Get cursor position relative to editor 1571 1602 let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor"); 1603 + 1604 + // Get selection rectangles if there's a selection 1605 + let selection_rects = if let Some((start, end)) = selection { 1606 + let (start, end) = if start <= end { (start, end) } else { (end, start) }; 1607 + get_selection_rects_relative(start, end, &offset_map, "markdown-editor") 1608 + } else { 1609 + vec![] 1610 + }; 1572 1611 1573 1612 let Some(rect) = rect else { 1613 + tracing::debug!( 1614 + "RemoteCursorIndicator: no rect for position {} (offset_map len: {})", 1615 + position, 1616 + offset_map.len() 1617 + ); 1574 1618 return rsx! {}; 1575 1619 }; 1576 1620 1621 + tracing::trace!( 1622 + "RemoteCursorIndicator: {} at ({}, {}) h={}, selection_rects={}", 1623 + display_name, 1624 + rect.x, 1625 + rect.y, 1626 + rect.height, 1627 + selection_rects.len() 1628 + ); 1629 + 1577 1630 let style = format!( 1578 1631 "left: {}px; top: {}px; --cursor-height: {}px; --cursor-color: {};", 1579 1632 rect.x, rect.y, rect.height, color_css 1580 1633 ); 1581 1634 1582 1635 rsx! { 1636 + // Selection highlight rectangles (rendered behind cursor) 1637 + for (i, sel_rect) in selection_rects.iter().enumerate() { 1638 + div { 1639 + key: "sel-{i}", 1640 + class: "remote-selection", 1641 + style: "left: {sel_rect.x}px; top: {sel_rect.y}px; width: {sel_rect.width}px; height: {sel_rect.height}px; background-color: {selection_color_css};", 1642 + } 1643 + } 1644 + 1583 1645 div { 1584 1646 class: "remote-cursor", 1585 1647 style: "{style}",
+120
crates/weaver-app/src/components/editor/cursor.rs
··· 307 307 ) -> Option<CursorRect> { 308 308 None 309 309 } 310 + 311 + /// A rectangle for part of a selection (one per line). 312 + #[derive(Debug, Clone, Copy)] 313 + pub struct SelectionRect { 314 + pub x: f64, 315 + pub y: f64, 316 + pub width: f64, 317 + pub height: f64, 318 + } 319 + 320 + /// Get screen rectangles for a selection range, relative to editor. 321 + /// 322 + /// Returns multiple rects if selection spans multiple lines. 323 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 324 + pub fn get_selection_rects_relative( 325 + start: usize, 326 + end: usize, 327 + offset_map: &[OffsetMapping], 328 + editor_id: &str, 329 + ) -> Vec<SelectionRect> { 330 + use wasm_bindgen::JsCast; 331 + 332 + if offset_map.is_empty() || start >= end { 333 + return vec![]; 334 + } 335 + 336 + let Some(window) = web_sys::window() else { 337 + return vec![]; 338 + }; 339 + let Some(document) = window.document() else { 340 + return vec![]; 341 + }; 342 + let Some(editor) = document.get_element_by_id(editor_id) else { 343 + return vec![]; 344 + }; 345 + let editor_rect = editor.get_bounding_client_rect(); 346 + 347 + // Find mappings for start and end 348 + let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { 349 + return vec![]; 350 + }; 351 + let Some((end_mapping, _)) = find_mapping_for_char(offset_map, end) else { 352 + return vec![]; 353 + }; 354 + 355 + // Get containers 356 + let start_container = document 357 + .get_element_by_id(&start_mapping.node_id) 358 + .or_else(|| { 359 + let selector = format!("[data-node-id='{}']", start_mapping.node_id); 360 + document.query_selector(&selector).ok().flatten() 361 + }); 362 + let end_container = document 363 + .get_element_by_id(&end_mapping.node_id) 364 + .or_else(|| { 365 + let selector = format!("[data-node-id='{}']", end_mapping.node_id); 366 + document.query_selector(&selector).ok().flatten() 367 + }); 368 + 369 + let (Some(start_container), Some(end_container)) = (start_container, end_container) else { 370 + return vec![]; 371 + }; 372 + 373 + // Create range 374 + let Ok(range) = document.create_range() else { 375 + return vec![]; 376 + }; 377 + 378 + // Set start 379 + if let Some(child_index) = start_mapping.child_index { 380 + let _ = range.set_start(&start_container, child_index as u32); 381 + } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() { 382 + let offset_in_range = start - start_mapping.char_range.start; 383 + let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 384 + if let Ok((text_node, node_offset)) = find_text_node_at_offset(&container_element, target_utf16_offset) { 385 + let _ = range.set_start(&text_node, node_offset as u32); 386 + } 387 + } 388 + 389 + // Set end 390 + if let Some(child_index) = end_mapping.child_index { 391 + let _ = range.set_end(&end_container, child_index as u32); 392 + } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 393 + let offset_in_range = end - end_mapping.char_range.start; 394 + let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 395 + if let Ok((text_node, node_offset)) = find_text_node_at_offset(&container_element, target_utf16_offset) { 396 + let _ = range.set_end(&text_node, node_offset as u32); 397 + } 398 + } 399 + 400 + // Get all rects (one per line) 401 + let Some(rects) = range.get_client_rects() else { 402 + return vec![]; 403 + }; 404 + let mut result = Vec::new(); 405 + 406 + for i in 0..rects.length() { 407 + if let Some(rect) = rects.get(i) { 408 + let rect: web_sys::DomRect = rect; 409 + result.push(SelectionRect { 410 + x: rect.x() - editor_rect.x(), 411 + y: rect.y() - editor_rect.y(), 412 + width: rect.width(), 413 + height: rect.height().max(16.0), 414 + }); 415 + } 416 + } 417 + 418 + result 419 + } 420 + 421 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 422 + pub fn get_selection_rects_relative( 423 + _start: usize, 424 + _end: usize, 425 + _offset_map: &[OffsetMapping], 426 + _editor_id: &str, 427 + ) -> Vec<SelectionRect> { 428 + vec![] 429 + }
+272 -30
crates/weaver-app/src/components/editor/sync.rs
··· 103 103 if let Ok(records_container) = 104 104 embeds_map.get_or_create_container("records", loro::LoroList::new()) 105 105 { 106 - tracing::debug!("Loro embeds map records len: {}", records_container.len()); 107 - 108 106 for i in 0..records_container.len() { 109 107 let Some(value) = records_container.get(i) else { 110 108 continue; ··· 132 130 .await 133 131 { 134 132 Ok(html) => { 135 - tracing::debug!("Pre-fetched embed from Loro map: {}", key_uri); 136 133 resolved.add_embed(key_uri, html, None); 137 134 } 138 135 Err(e) => { ··· 148 145 149 146 // Strategy 2: If no embeds found in Loro map, parse markdown text 150 147 if resolved.embed_content.is_empty() { 151 - use weaver_common::{collect_refs_from_markdown, ExtractedRef}; 148 + use weaver_common::{ExtractedRef, collect_refs_from_markdown}; 152 149 153 150 let text = doc.get_text("content"); 154 151 let markdown = text.to_string(); 155 152 156 153 if !markdown.is_empty() { 157 - tracing::debug!("Falling back to markdown parsing for embeds"); 158 154 let refs = collect_refs_from_markdown(&markdown); 159 155 160 156 for extracted in refs { ··· 166 162 167 163 match weaver_renderer::atproto::fetch_and_render(&key_uri, fetcher).await { 168 164 Ok(html) => { 169 - tracing::debug!("Pre-fetched embed from markdown: {}", uri); 170 165 resolved.add_embed(key_uri, html, None); 171 166 } 172 167 Err(e) => { ··· 707 702 // Use inline for small diffs, blob for larger ones 708 703 let (blob_ref, inline_diff): (Option<jacquard::types::blob::BlobRef<'static>>, _) = 709 704 if updates.len() <= INLINE_THRESHOLD { 710 - tracing::debug!("Using inline diff ({} bytes)", updates.len()); 711 705 (None, Some(jacquard::bytes::Bytes::from(updates))) 712 706 } else { 713 - tracing::debug!("Using blob diff ({} bytes)", updates.len()); 714 707 let mime_type = MimeType::new_static("application/octet-stream"); 715 708 let blob = client.upload_blob(updates, mime_type).await.map_err(|e| { 716 709 WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)) ··· 976 969 977 970 // Get the last seen diff rkey for this root (if any) 978 971 let after_rkey = root_uri.as_ref().and_then(|uri| { 979 - last_seen_diffs.get(uri).and_then(|diff_uri| { 980 - diff_uri.rkey().map(|rk| rk.0.to_string()) 981 - }) 972 + last_seen_diffs 973 + .get(uri) 974 + .and_then(|diff_uri| diff_uri.rkey().map(|rk| rk.0.to_string())) 982 975 }); 983 976 984 977 // Load state from this root (skipping already-seen diffs) 985 - if let Some(pds_state) = load_edit_state_from_root_id(fetcher, root_id, after_rkey.as_deref()).await? { 978 + if let Some(pds_state) = 979 + load_edit_state_from_root_id(fetcher, root_id, after_rkey.as_deref()).await? 980 + { 986 981 // Import root snapshot into merged doc 987 982 if let Err(e) = merged_doc.import(&pds_state.root_snapshot) { 988 983 tracing::warn!("Failed to import root snapshot from {}: {:?}", root_did, e); ··· 1112 1107 // Skip diffs we've already seen (rkey/TID is lexicographically sortable by time) 1113 1108 if let Some(after) = after_rkey { 1114 1109 if rkey_str <= after { 1115 - tracing::debug!("Skipping already-seen diff rkey: {}", rkey_str); 1110 + tracing::trace!("Skipping already-seen diff rkey: {}", rkey_str); 1116 1111 continue; 1117 1112 } 1118 1113 } ··· 1365 1360 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1366 1361 #[component] 1367 1362 pub fn RealTimeSync(props: RealTimeSyncProps) -> Element { 1363 + use crate::collab_context::try_use_collab_debug; 1368 1364 use tokio::sync::mpsc; 1365 + use weaver_common::WeaverExt; 1369 1366 use weaver_common::transport::{CollabMessage, CollabSession, SessionEvent}; 1370 - use weaver_common::WeaverExt; 1371 1367 1372 1368 let collab_node = use_collab_node(); 1373 1369 let fetcher = use_context::<crate::fetch::Fetcher>(); 1374 1370 let mut session: Signal<Option<Arc<CollabSession>>> = use_signal(|| None); 1375 1371 // URI of our published session record (for cleanup) 1376 1372 let mut session_record_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 1373 + // Debug state for display in editor debug panel (optional, may not be provided) 1374 + let debug_state = try_use_collab_debug(); 1377 1375 // Channel for sending local updates from Loro callback to async broadcast task 1378 1376 let mut update_tx: Signal<Option<mpsc::UnboundedSender<Vec<u8>>>> = use_signal(|| None); 1379 1377 // Channel for sending cursor updates ··· 1438 1436 cursor_tx.set(Some(ctx)); 1439 1437 1440 1438 // Subscribe to local updates from Loro - fires when local changes are committed 1441 - let sub = doc_for_join.loro_doc().subscribe_local_update(Box::new(move |update| { 1442 - tracing::debug!("RealTimeSync: local update ({} bytes)", update.len()); 1443 - if let Err(e) = tx.send(update.to_vec()) { 1444 - tracing::warn!("RealTimeSync: failed to queue update: {}", e); 1445 - } 1446 - true // Keep subscription active 1447 - })); 1439 + let sub = doc_for_join 1440 + .loro_doc() 1441 + .subscribe_local_update(Box::new(move |update| { 1442 + tracing::debug!("RealTimeSync: local update ({} bytes)", update.len()); 1443 + if let Err(e) = tx.send(update.to_vec()) { 1444 + tracing::warn!("RealTimeSync: failed to queue update: {}", e); 1445 + } 1446 + true // Keep subscription active 1447 + })); 1448 1448 _subscription.set(Some(sub)); 1449 1449 1450 1450 let doc_for_recv = doc_for_join.clone(); ··· 1455 1455 // Derive topic from resource URI 1456 1456 let topic = CollabSession::topic_from_uri(uri.as_str()); 1457 1457 1458 + // Wait for relay connection before discovering peers or publishing session 1459 + // Browser clients REQUIRE relay for peer connectivity 1460 + let relay_url = node.wait_for_relay().await; 1461 + tracing::info!( 1462 + relay_url = %relay_url, 1463 + "RealTimeSync: relay connection ready" 1464 + ); 1465 + 1466 + // Update debug state with node info 1467 + if let Some(mut ds) = debug_state { 1468 + ds.with_mut(|s| { 1469 + s.node_id = Some(node.node_id_string()); 1470 + s.relay_url = Some(relay_url.clone()); 1471 + }); 1472 + } 1473 + 1458 1474 // Discover existing session peers for bootstrap 1459 1475 let bootstrap_peers = match fetcher.find_session_peers(&uri).await { 1460 1476 Ok(peers) => { 1461 1477 tracing::info!("RealTimeSync: found {} existing peers", peers.len()); 1478 + if let Some(mut ds) = debug_state { 1479 + ds.with_mut(|s| s.discovered_peers = peers.len()); 1480 + } 1481 + for p in &peers { 1482 + tracing::info!( 1483 + did = %p.did, 1484 + node_id = %p.node_id, 1485 + relay_url = ?p.relay_url, 1486 + expires_at = ?p.expires_at, 1487 + "RealTimeSync: discovered peer" 1488 + ); 1489 + } 1462 1490 peers 1463 1491 .into_iter() 1464 1492 .filter_map(|p| { ··· 1468 1496 } 1469 1497 Err(e) => { 1470 1498 tracing::warn!("RealTimeSync: failed to find peers: {}", e); 1499 + if let Some(mut ds) = debug_state { 1500 + ds.with_mut(|s| s.last_error = Some(format!("peer discovery: {}", e))); 1501 + } 1471 1502 vec![] 1472 1503 } 1473 1504 }; ··· 1478 1509 .create_collab_session( 1479 1510 &resource_ref_for_spawn, 1480 1511 &node_id_str, 1481 - None, // relay_url - could add if needed 1512 + Some(&relay_url), 1482 1513 Some(SESSION_TTL_MINUTES), 1483 1514 ) 1484 1515 .await 1485 1516 { 1486 1517 Ok(uri) => { 1487 1518 tracing::info!("RealTimeSync: published session record: {}", uri); 1519 + if let Some(mut ds) = debug_state { 1520 + ds.with_mut(|s| s.session_record_uri = Some(uri.to_string())); 1521 + } 1488 1522 session_record_uri.set(Some(uri)); 1489 1523 } 1490 1524 Err(e) => { 1491 1525 tracing::warn!("RealTimeSync: failed to publish session record: {}", e); 1526 + if let Some(mut ds) = debug_state { 1527 + ds.with_mut(|s| s.last_error = Some(format!("publish session: {}", e))); 1528 + } 1492 1529 } 1493 1530 } 1494 1531 1532 + // Clone before join() consumes them 1533 + let node_for_discovery = node.clone(); 1534 + let bootstrap_peers_set = bootstrap_peers.clone(); 1535 + 1495 1536 match CollabSession::join(node, topic, bootstrap_peers).await { 1496 1537 Ok((collab_session, mut event_stream)) => { 1497 1538 let collab_session = Arc::new(collab_session); 1498 1539 session.set(Some(collab_session.clone())); 1540 + if let Some(mut ds) = debug_state { 1541 + ds.with_mut(|s| s.is_joined = true); 1542 + } 1499 1543 1500 1544 tracing::info!("RealTimeSync: joined session for {}", uri); 1501 1545 1546 + // Broadcast Join message to announce ourselves 1547 + let our_did = fetcher.current_did().await; 1548 + let display_name = if let Some(ref did) = our_did { 1549 + use jacquard::types::ident::AtIdentifier; 1550 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 1551 + 1552 + let ident = AtIdentifier::Did(did.clone()); 1553 + fetcher 1554 + .fetch_profile(&ident) 1555 + .await 1556 + .ok() 1557 + .and_then(|p| match &p.inner { 1558 + ProfileDataViewInner::ProfileView(pv) => { 1559 + pv.display_name.as_ref().map(|s| s.to_string()) 1560 + } 1561 + ProfileDataViewInner::ProfileViewDetailed(pv) => { 1562 + pv.display_name.as_ref().map(|s| s.to_string()) 1563 + } 1564 + _ => None, 1565 + }) 1566 + .unwrap_or_else(|| "Collaborator".to_string()) 1567 + } else { 1568 + "Collaborator".to_string() 1569 + }; 1570 + let join_msg = CollabMessage::Join { 1571 + did: our_did.map(|d| d.to_string()).unwrap_or_default(), 1572 + display_name, 1573 + }; 1574 + if let Err(e) = collab_session.broadcast(&join_msg).await { 1575 + tracing::warn!("RealTimeSync: failed to broadcast Join: {}", e); 1576 + } 1577 + 1578 + // Request sync from existing peers 1579 + // Convert our version vector to wire format 1580 + let our_vv = doc_for_recv.version_vector(); 1581 + let have_version: Vec<(u64, u64)> = our_vv 1582 + .iter() 1583 + .map(|(peer, counter)| (*peer, *counter as u64)) 1584 + .collect(); 1585 + let sync_request = CollabMessage::SyncRequest { have_version }; 1586 + if let Err(e) = collab_session.broadcast(&sync_request).await { 1587 + tracing::warn!("RealTimeSync: failed to broadcast SyncRequest: {}", e); 1588 + } else { 1589 + tracing::debug!("RealTimeSync: sent sync request ({} vv entries)", our_vv.len()); 1590 + } 1591 + 1592 + // Spawn TTL refresh task - keeps our session record alive 1593 + let session_uri_for_refresh = session_record_uri.clone(); 1594 + let fetcher_for_refresh = fetcher.clone(); 1595 + spawn(async move { 1596 + // Refresh every 5 minutes (TTL is 15 min, so plenty of buffer) 1597 + let mut interval = n0_future::time::interval( 1598 + n0_future::time::Duration::from_secs(5 * 60), 1599 + ); 1600 + loop { 1601 + interval.tick().await; 1602 + if let Some(uri) = session_uri_for_refresh.peek().clone() { 1603 + tracing::debug!("RealTimeSync: refreshing session TTL"); 1604 + if let Err(e) = fetcher_for_refresh 1605 + .refresh_collab_session(&uri, SESSION_TTL_MINUTES) 1606 + .await 1607 + { 1608 + tracing::warn!( 1609 + "RealTimeSync: failed to refresh session: {}", 1610 + e 1611 + ); 1612 + } 1613 + } 1614 + } 1615 + }); 1616 + 1502 1617 // Spawn broadcast task - sends local updates to gossip 1503 1618 let session_for_broadcast = collab_session.clone(); 1504 1619 spawn(async move { ··· 1532 1647 } 1533 1648 }); 1534 1649 1650 + // Spawn periodic peer discovery task 1651 + // This handles the race condition where peers publish sessions 1652 + // at different times and might miss each other on initial discovery 1653 + let session_for_discovery = collab_session.clone(); 1654 + let fetcher_for_discovery = fetcher.clone(); 1655 + let uri_for_discovery = uri.clone(); 1656 + let our_node_id = node_for_discovery.node_id(); 1657 + let mut known_peers: std::collections::HashSet<weaver_common::transport::EndpointId> = 1658 + bootstrap_peers_set.iter().cloned().collect(); 1659 + spawn(async move { 1660 + // Check for new peers every 30 seconds 1661 + let mut interval = 1662 + n0_future::time::interval(n0_future::time::Duration::from_secs(30)); 1663 + loop { 1664 + interval.tick().await; 1665 + tracing::debug!("RealTimeSync: periodic discovery tick"); 1666 + match fetcher_for_discovery 1667 + .find_session_peers(&uri_for_discovery) 1668 + .await 1669 + { 1670 + Ok(peers) => { 1671 + tracing::info!( 1672 + "RealTimeSync: periodic discovery found {} session records", 1673 + peers.len() 1674 + ); 1675 + for p in &peers { 1676 + tracing::debug!( 1677 + " - peer: {} (relay: {:?}, expires: {:?})", 1678 + p.node_id, 1679 + p.relay_url, 1680 + p.expires_at 1681 + ); 1682 + } 1683 + // Filter: parse node ID, exclude ourselves, exclude already known 1684 + let new_peers: Vec<_> = peers 1685 + .into_iter() 1686 + .filter_map(|p| { 1687 + weaver_common::transport::parse_node_id(&p.node_id) 1688 + .ok() 1689 + }) 1690 + .filter(|id| *id != our_node_id) 1691 + .filter(|id| !known_peers.contains(id)) 1692 + .collect(); 1693 + 1694 + if !new_peers.is_empty() { 1695 + tracing::info!( 1696 + "RealTimeSync: periodic discovery found {} NEW peers", 1697 + new_peers.len() 1698 + ); 1699 + for p in &new_peers { 1700 + known_peers.insert(*p); 1701 + } 1702 + if let Err(e) = 1703 + session_for_discovery.join_peers(new_peers).await 1704 + { 1705 + tracing::warn!( 1706 + "RealTimeSync: failed to join discovered peers: {}", 1707 + e 1708 + ); 1709 + } 1710 + } 1711 + } 1712 + Err(e) => { 1713 + tracing::warn!( 1714 + "RealTimeSync: periodic peer discovery failed: {}", 1715 + e 1716 + ); 1717 + } 1718 + } 1719 + } 1720 + }); 1721 + 1535 1722 // Spawn event receiver task - receives updates from peers 1536 1723 let mut doc_for_recv = doc_for_recv.clone(); 1724 + let session_for_sync = collab_session.clone(); 1537 1725 spawn(async move { 1538 1726 use n0_future::StreamExt; 1539 1727 1540 - while let Some(event) = event_stream.next().await { 1728 + while let Some(result) = event_stream.next().await { 1729 + let event = match result { 1730 + Ok(e) => e, 1731 + Err(e) => { 1732 + tracing::error!("RealTimeSync: event stream error: {}", e); 1733 + break; 1734 + } 1735 + }; 1541 1736 match event { 1542 1737 SessionEvent::Message { from, message } => { 1543 1738 match message { ··· 1559 1754 selection, 1560 1755 .. 1561 1756 } => { 1562 - presence.write().update_cursor( 1563 - &from, 1564 - position, 1565 - selection, 1566 - ); 1757 + // Add peer if not known (cursor might arrive before Join) 1758 + let mut p = presence.write(); 1759 + if !p.contains(&from) { 1760 + p.add_collaborator( 1761 + from, 1762 + "unknown".into(), 1763 + "Peer".into(), 1764 + ); 1765 + } 1766 + p.update_cursor(&from, position, selection); 1567 1767 } 1568 1768 CollabMessage::Join { did, display_name } => { 1569 1769 tracing::info!( ··· 1586 1786 "RealTimeSync: sync request (have {} entries)", 1587 1787 have_version.len() 1588 1788 ); 1589 - // TODO: Send snapshot or updates based on their version 1789 + // Convert their version vector from wire format 1790 + let their_vv: loro::VersionVector = have_version 1791 + .into_iter() 1792 + .map(|(peer, counter)| (peer, counter as i32)) 1793 + .collect(); 1794 + 1795 + // Export updates they don't have 1796 + if let Some(data) = 1797 + doc_for_recv.export_updates_from(&their_vv) 1798 + { 1799 + tracing::info!( 1800 + "RealTimeSync: sending {} bytes to sync peer", 1801 + data.len() 1802 + ); 1803 + let response = CollabMessage::SyncResponse { 1804 + data, 1805 + is_snapshot: false, 1806 + }; 1807 + if let Err(e) = 1808 + session_for_sync.broadcast(&response).await 1809 + { 1810 + tracing::warn!( 1811 + "RealTimeSync: failed to send sync response: {}", 1812 + e 1813 + ); 1814 + } 1815 + } else { 1816 + tracing::debug!( 1817 + "RealTimeSync: no updates to send (peer is up to date)" 1818 + ); 1819 + } 1590 1820 } 1591 - _ => {} 1821 + CollabMessage::SyncResponse { data, is_snapshot } => { 1822 + tracing::info!( 1823 + "RealTimeSync: received sync response ({} bytes, snapshot: {})", 1824 + data.len(), 1825 + is_snapshot 1826 + ); 1827 + if let Err(e) = doc_for_recv.import_updates(&data) { 1828 + tracing::warn!( 1829 + "RealTimeSync: failed to import sync response: {:?}", 1830 + e 1831 + ); 1832 + } 1833 + } 1592 1834 } 1593 1835 } 1594 1836 SessionEvent::PeerJoined(peer) => {
+2 -1
crates/weaver-app/src/main.rs
··· 148 148 ); 149 149 150 150 // Filter out noisy crates 151 - let filter = EnvFilter::new("debug,loro_internal=warn"); 151 + let filter = 152 + EnvFilter::new("debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info"); 152 153 153 154 let reg = Registry::default() 154 155 .with(filter)
-1
crates/weaver-common/Cargo.toml
··· 58 58 send_wrapper = "0.6" 59 59 wasmworker = "0.1" 60 60 wasmworker-proc-macro = "0.1" 61 - iroh-quinn = { version = "0.14", default-features = false } 62 61 ring = { version = "0.17", default-features = false, features = ["wasm32_unknown_unknown_js"]} 63 62 getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } 64 63
+120
crates/weaver-common/src/agent.rs
··· 1420 1420 }; 1421 1421 1422 1422 if !accept_output.records.is_empty() { 1423 + // Both parties in a valid invite+accept pair are authorized 1424 + let inviter_did = record_id.did.clone().into_static(); 1425 + collaborators.push(inviter_did); 1423 1426 collaborators.push(invitee_did); 1424 1427 } 1425 1428 } 1429 + 1430 + // Deduplicate (someone might appear in multiple pairs) 1431 + collaborators.sort(); 1432 + collaborators.dedup(); 1426 1433 1427 1434 Ok(collaborators) 1428 1435 } ··· 1906 1913 use jacquard::types::string::Datetime; 1907 1914 use weaver_api::sh_weaver::collab::session::Session; 1908 1915 1916 + // Clean up any expired sessions first 1917 + let _ = self.cleanup_expired_sessions().await; 1918 + 1909 1919 let now_chrono = chrono::Utc::now().fixed_offset(); 1910 1920 let now = Datetime::new(now_chrono); 1911 1921 let expires_at = ttl_minutes.map(|mins| { ··· 1973 1983 } 1974 1984 } 1975 1985 1986 + /// Update the relay URL in an existing session record. 1987 + /// 1988 + /// Called when the relay connection changes during a session. 1989 + fn update_collab_session_relay<'a>( 1990 + &'a self, 1991 + session_uri: &'a AtUri<'a>, 1992 + relay_url: Option<&'a str>, 1993 + ) -> impl Future<Output = Result<(), WeaverError>> + 'a { 1994 + async move { 1995 + use weaver_api::sh_weaver::collab::session::Session; 1996 + 1997 + let relay_uri = relay_url 1998 + .map(|url| jacquard::types::string::Uri::new(url)) 1999 + .transpose() 2000 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid relay URL")))?; 2001 + 2002 + self.update_record::<Session>(session_uri, |session| { 2003 + session.relay_url = relay_uri.clone(); 2004 + }) 2005 + .await?; 2006 + Ok(()) 2007 + } 2008 + } 2009 + 2010 + /// Delete all expired session records for the current user. 2011 + /// 2012 + /// Called before creating a new session to clean up stale records. 2013 + fn cleanup_expired_sessions<'a>( 2014 + &'a self, 2015 + ) -> impl Future<Output = Result<u32, WeaverError>> + 'a 2016 + where 2017 + Self: Sized, 2018 + { 2019 + async move { 2020 + use jacquard::types::nsid::Nsid; 2021 + use weaver_api::com_atproto::repo::list_records::ListRecords; 2022 + use weaver_api::sh_weaver::collab::session::Session; 2023 + 2024 + let (did, _) = self.session_info().await.ok_or_else(|| { 2025 + AgentError::from(ClientError::invalid_request("No active session")) 2026 + })?; 2027 + let now = chrono::Utc::now(); 2028 + let mut deleted = 0u32; 2029 + 2030 + // List all our session records 2031 + let collection = 2032 + Nsid::new("sh.weaver.collab.session").map_err(WeaverError::AtprotoString)?; 2033 + let request = ListRecords::new() 2034 + .repo(did.clone()) 2035 + .collection(collection) 2036 + .limit(100) 2037 + .build(); 2038 + 2039 + let response = self.send(request).await.map_err(AgentError::from)?; 2040 + let output = response.into_output().map_err(|e| { 2041 + AgentError::from(ClientError::invalid_request(format!( 2042 + "Failed to list sessions: {}", 2043 + e 2044 + ))) 2045 + })?; 2046 + 2047 + for record in output.records { 2048 + if let Ok(session) = jacquard::from_data::<Session>(&record.value) { 2049 + // Check if expired 2050 + if let Some(ref expires_at) = session.expires_at { 2051 + let expires_str = expires_at.as_str(); 2052 + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_str) { 2053 + if expires.with_timezone(&chrono::Utc) < now { 2054 + // Delete expired session 2055 + if let Some(rkey) = record.uri.rkey() { 2056 + if let Err(e) = 2057 + self.delete_record::<Session>(rkey.clone()).await 2058 + { 2059 + tracing::warn!("Failed to delete expired session: {}", e); 2060 + } else { 2061 + deleted += 1; 2062 + } 2063 + } 2064 + } 2065 + } 2066 + } 2067 + } 2068 + } 2069 + 2070 + if deleted > 0 { 2071 + tracing::info!("Cleaned up {} expired session records", deleted); 2072 + } 2073 + 2074 + Ok(deleted) 2075 + } 2076 + } 2077 + 1976 2078 /// Find active collaboration sessions for a resource. 1977 2079 /// 1978 2080 /// Queries Constellation for session records referencing the given resource, ··· 1991 2093 use weaver_api::sh_weaver::collab::session::Session; 1992 2094 1993 2095 const SESSION_NSID: &str = "sh.weaver.collab.session"; 2096 + 2097 + // Get authorized collaborators (owner is checked separately via URI authority) 2098 + let collaborators: std::collections::HashSet<Did<'static>> = self 2099 + .find_collaborators_for_resource(resource_uri) 2100 + .await 2101 + .unwrap_or_default() 2102 + .into_iter() 2103 + .collect(); 1994 2104 1995 2105 let constellation_url = Url::parse(CONSTELLATION_URL).map_err(|e| { 1996 2106 AgentError::from(ClientError::invalid_request(format!( ··· 2053 2163 if *expires_at < now { 2054 2164 continue; // Session expired 2055 2165 } 2166 + } 2167 + 2168 + // Check if peer is authorized (has valid invite+accept pair) 2169 + let peer_did = record_id.did.clone().into_static(); 2170 + if !collaborators.contains(&peer_did) { 2171 + tracing::debug!( 2172 + peer = %peer_did, 2173 + "Filtering out unauthorized session peer" 2174 + ); 2175 + continue; 2056 2176 } 2057 2177 2058 2178 peers.push(SessionPeer {
+75 -2
crates/weaver-common/src/transport/messages.rs
··· 1 1 //! Wire protocol for collaborative editing messages. 2 2 3 + use iroh::{PublicKey, SecretKey, Signature}; 3 4 use serde::{Deserialize, Serialize}; 4 5 5 6 /// Messages exchanged between collaborators over gossip. ··· 53 54 } 54 55 55 56 impl CollabMessage { 56 - /// Serialize message to CBOR bytes for wire transmission 57 + /// Serialize message to postcard bytes for wire transmission. 57 58 pub fn to_bytes(&self) -> Result<Vec<u8>, postcard::Error> { 58 59 postcard::to_stdvec(self) 59 60 } 60 61 61 - /// Deserialize message from CBOR bytes 62 + /// Deserialize message from postcard bytes. 62 63 pub fn from_bytes(bytes: &[u8]) -> Result<Self, postcard::Error> { 63 64 postcard::from_bytes(bytes) 65 + } 66 + } 67 + 68 + /// A signed message wrapper for authenticated transport. 69 + /// 70 + /// Includes the sender's public key so receivers can verify without context. 71 + #[derive(Debug, Clone, Serialize, Deserialize)] 72 + pub struct SignedMessage { 73 + /// Sender's public key (also their EndpointId). 74 + pub from: PublicKey, 75 + /// The serialized TimestampedMessage (postcard bytes). 76 + pub data: Vec<u8>, 77 + /// Ed25519 signature over data. 78 + pub signature: Signature, 79 + } 80 + 81 + /// Versioned wire format with timestamp. 82 + #[derive(Debug, Clone, Serialize, Deserialize)] 83 + enum WireMessage { 84 + V0 { timestamp: u64, message: CollabMessage }, 85 + } 86 + 87 + /// A verified message with sender and timestamp info. 88 + #[derive(Debug, Clone)] 89 + pub struct ReceivedMessage { 90 + /// Sender's public key. 91 + pub from: PublicKey, 92 + /// When the message was sent (micros since epoch). 93 + pub timestamp: u64, 94 + /// The decoded message. 95 + pub message: CollabMessage, 96 + } 97 + 98 + /// Error type for signed message operations. 99 + #[derive(Debug, thiserror::Error)] 100 + pub enum SignedMessageError { 101 + #[error("serialization failed: {0}")] 102 + Serialization(#[from] postcard::Error), 103 + #[error("signature verification failed")] 104 + InvalidSignature, 105 + } 106 + 107 + impl SignedMessage { 108 + /// Sign a message and encode to bytes for wire transmission. 109 + pub fn sign_and_encode(secret_key: &SecretKey, message: &CollabMessage) -> Result<Vec<u8>, SignedMessageError> { 110 + use web_time::SystemTime; 111 + 112 + let timestamp = SystemTime::now() 113 + .duration_since(SystemTime::UNIX_EPOCH) 114 + .unwrap() 115 + .as_micros() as u64; 116 + let wire = WireMessage::V0 { timestamp, message: message.clone() }; 117 + let data = postcard::to_stdvec(&wire)?; 118 + let signature = secret_key.sign(&data); 119 + let from = secret_key.public(); 120 + let signed = Self { from, data, signature }; 121 + Ok(postcard::to_stdvec(&signed)?) 122 + } 123 + 124 + /// Decode from bytes and verify signature. 125 + pub fn decode_and_verify(bytes: &[u8]) -> Result<ReceivedMessage, SignedMessageError> { 126 + let signed: Self = postcard::from_bytes(bytes)?; 127 + signed.from 128 + .verify(&signed.data, &signed.signature) 129 + .map_err(|_| SignedMessageError::InvalidSignature)?; 130 + let wire: WireMessage = postcard::from_bytes(&signed.data)?; 131 + let WireMessage::V0 { timestamp, message } = wire; 132 + Ok(ReceivedMessage { 133 + from: signed.from, 134 + timestamp, 135 + message, 136 + }) 64 137 } 65 138 } 66 139
+1 -4
crates/weaver-common/src/transport/mod.rs
··· 14 14 15 15 pub use discovery::{node_id_to_string, parse_node_id, DiscoveredPeer, DiscoveryError}; 16 16 pub use iroh::EndpointId; 17 - pub use messages::CollabMessage; 17 + pub use messages::{CollabMessage, ReceivedMessage, SignedMessage, SignedMessageError}; 18 18 pub use node::{CollabNode, TransportError}; 19 19 pub use presence::{Collaborator, PresenceTracker, RemoteCursor}; 20 20 pub use session::{CollabSession, SessionError, SessionEvent, TopicId}; 21 - 22 - /// ALPN protocol identifier for weaver collaboration 23 - pub const WEAVER_GOSSIP_ALPN: &[u8] = b"weaver/collab/0";
+51 -5
crates/weaver-common/src/transport/node.rs
··· 3 3 use iroh::Endpoint; 4 4 use iroh::EndpointId; 5 5 use iroh::SecretKey; 6 - use iroh_gossip::net::Gossip; 6 + use iroh_gossip::net::{GOSSIP_ALPN, Gossip}; 7 7 use miette::Diagnostic; 8 8 use std::sync::Arc; 9 - 10 - use super::WEAVER_GOSSIP_ALPN; 11 9 12 10 /// Error type for transport operations 13 11 #[derive(Debug, thiserror::Error, Diagnostic)] ··· 48 46 // In native, this can do direct P2P with relay fallback 49 47 let endpoint = Endpoint::builder() 50 48 .secret_key(secret_key.clone()) 51 - .alpns(vec![WEAVER_GOSSIP_ALPN.to_vec()]) 49 + .alpns(vec![GOSSIP_ALPN.to_vec()]) 52 50 .bind() 53 51 .await 54 52 .map_err(|e| TransportError::Bind(Box::new(e)))?; ··· 58 56 59 57 // Build router to dispatch incoming connections by ALPN 60 58 let router = iroh::protocol::Router::builder(endpoint.clone()) 61 - .accept(WEAVER_GOSSIP_ALPN, gossip.clone()) 59 + .accept(GOSSIP_ALPN, gossip.clone()) 62 60 .spawn(); 63 61 64 62 tracing::info!(node_id = %endpoint.id(), "CollabNode started"); ··· 97 95 /// Get a clone of the secret key (for session persistence if needed). 98 96 pub fn secret_key(&self) -> SecretKey { 99 97 self.secret_key.clone() 98 + } 99 + 100 + /// Get the relay URL this node is connected to (if any). 101 + /// 102 + /// This should be published in session records so other peers can connect 103 + /// via relay (essential for browser-to-browser connections). 104 + pub fn relay_url(&self) -> Option<String> { 105 + self.endpoint 106 + .addr() 107 + .relay_urls() 108 + .next() 109 + .map(|url| url.to_string()) 110 + } 111 + 112 + /// Get the full node address including relay info. 113 + /// 114 + /// Use this when you need to connect to this node from another peer. 115 + pub fn node_addr(&self) -> iroh::EndpointAddr { 116 + self.endpoint.addr() 117 + } 118 + 119 + /// Wait for the endpoint to be online (relay connected). 120 + /// 121 + /// This should be called before publishing session records to ensure 122 + /// the relay URL is available for peer discovery. For browser clients, 123 + /// relay is required - we wait indefinitely since there's no fallback. 124 + pub async fn wait_online(&self) { 125 + self.endpoint.online().await; 126 + } 127 + 128 + /// Wait for relay connection and return the relay URL. 129 + /// 130 + /// Waits indefinitely for relay - browser clients require relay URLs 131 + /// for peer discovery. Returns the relay URL once connected. 132 + pub async fn wait_for_relay(&self) -> String { 133 + self.endpoint.online().await; 134 + // After online(), relay_url should always be Some for browser clients 135 + self.relay_url() 136 + .expect("relay URL should be available after online()") 137 + } 138 + 139 + /// Watch for address changes (including relay URL changes). 140 + /// 141 + /// Returns a stream that yields the address on each change. 142 + /// Use this to detect relay URL changes and update session records. 143 + pub fn watch_addr(&self) -> n0_future::boxed::BoxStream<iroh::EndpointAddr> { 144 + use iroh::Watcher; 145 + Box::pin(self.endpoint.watch_addr().stream()) 100 146 } 101 147 }
+93 -30
crates/weaver-common/src/transport/session.rs
··· 9 9 use n0_future::boxed::BoxStream; 10 10 use n0_future::stream; 11 11 12 - use super::{CollabMessage, CollabNode}; 12 + use super::{CollabMessage, CollabNode, SignedMessage}; 13 13 14 14 /// Topic ID for a gossip session - derived from resource URI. 15 15 pub type TopicId = iroh_gossip::TopicId; ··· 57 57 pub struct CollabSession { 58 58 topic: TopicId, 59 59 sender: GossipSender, 60 - #[allow(dead_code)] 61 60 node: Arc<CollabNode>, 62 61 } 63 62 ··· 79 78 node: Arc<CollabNode>, 80 79 topic: TopicId, 81 80 bootstrap_peers: Vec<EndpointId>, 82 - ) -> Result<(Self, BoxStream<SessionEvent>), SessionError> { 81 + ) -> Result<(Self, BoxStream<Result<SessionEvent, SessionError>>), SessionError> { 82 + tracing::info!( 83 + topic = ?topic, 84 + bootstrap_count = bootstrap_peers.len(), 85 + "CollabSession: joining topic" 86 + ); 87 + 88 + for peer in &bootstrap_peers { 89 + tracing::debug!(peer = %peer, "CollabSession: bootstrap peer"); 90 + } 91 + 83 92 // Subscribe to the gossip topic 84 93 let (sender, receiver) = node 85 94 .gossip() 86 - .subscribe(topic, bootstrap_peers) 95 + .subscribe_and_join(topic, bootstrap_peers) 87 96 .await 88 97 .map_err(|e| SessionError::Subscribe(Box::new(e)))? 89 98 .split(); 99 + 100 + tracing::info!("CollabSession: subscribed to gossip topic"); 90 101 91 102 let session = Self { 92 103 topic, ··· 101 112 } 102 113 103 114 /// Convert gossip receiver into a stream of session events. 104 - fn event_stream(receiver: GossipReceiver) -> BoxStream<SessionEvent> { 105 - let stream = stream::unfold(receiver, |mut receiver| async move { 115 + fn event_stream(receiver: GossipReceiver) -> BoxStream<Result<SessionEvent, SessionError>> { 116 + let stream = stream::try_unfold(receiver, |mut receiver| async move { 106 117 loop { 107 - match receiver.next().await { 108 - Some(Ok(event)) => { 109 - let session_event = match event { 110 - Event::NeighborUp(peer) => SessionEvent::PeerJoined(peer), 111 - Event::NeighborDown(peer) => SessionEvent::PeerLeft(peer), 112 - Event::Received(msg) => match CollabMessage::from_bytes(&msg.content) { 113 - Ok(message) => SessionEvent::Message { 114 - from: msg.delivered_from, 115 - message, 116 - }, 117 - Err(e) => { 118 - tracing::warn!(?e, "failed to decode collab message"); 118 + let Some(event) = receiver.try_next().await.map_err(|e| { 119 + tracing::error!(?e, "CollabSession: gossip receiver error"); 120 + SessionError::Decode(Box::new(e)) 121 + })? 122 + else { 123 + tracing::debug!("CollabSession: gossip stream ended"); 124 + return Ok(None); 125 + }; 126 + 127 + tracing::debug!(?event, "CollabSession: raw gossip event"); 128 + let session_event = match event { 129 + Event::NeighborUp(peer) => { 130 + tracing::info!(peer = %peer, "CollabSession: neighbor up"); 131 + SessionEvent::PeerJoined(peer) 132 + } 133 + Event::NeighborDown(peer) => { 134 + tracing::info!(peer = %peer, "CollabSession: neighbor down"); 135 + SessionEvent::PeerLeft(peer) 136 + } 137 + Event::Received(msg) => { 138 + tracing::debug!( 139 + from = %msg.delivered_from, 140 + bytes = msg.content.len(), 141 + "CollabSession: received message" 142 + ); 143 + match SignedMessage::decode_and_verify(&msg.content) { 144 + Ok(received) => { 145 + // Verify claimed sender matches transport sender 146 + if received.from != msg.delivered_from { 147 + tracing::warn!( 148 + claimed = %received.from, 149 + transport = %msg.delivered_from, 150 + "sender mismatch - possible spoofing attempt" 151 + ); 119 152 continue; 120 153 } 121 - }, 122 - Event::Lagged => { 123 - tracing::warn!("gossip receiver lagged, some messages may be lost"); 154 + SessionEvent::Message { 155 + from: received.from, 156 + message: received.message, 157 + } 158 + } 159 + Err(e) => { 160 + tracing::warn!(?e, "failed to verify/decode signed message"); 124 161 continue; 125 162 } 126 - }; 127 - return Some((session_event, receiver)); 163 + } 128 164 } 129 - Some(Err(e)) => { 130 - tracing::warn!(?e, "gossip receiver error"); 165 + Event::Lagged => { 166 + tracing::warn!("gossip receiver lagged, some messages may be lost"); 131 167 continue; 132 168 } 133 - None => return None, 134 - } 169 + }; 170 + break Ok(Some((session_event, receiver))); 135 171 } 136 172 }); 137 173 138 174 Box::pin(stream) 139 175 } 140 176 141 - /// Broadcast a message to all peers in the session. 177 + /// Broadcast a signed message to all peers in the session. 142 178 pub async fn broadcast(&self, message: &CollabMessage) -> Result<(), SessionError> { 143 - let bytes = message 144 - .to_bytes() 179 + let bytes = SignedMessage::sign_and_encode(&self.node.secret_key(), message) 145 180 .map_err(|e| SessionError::Broadcast(Box::new(e)))?; 181 + 182 + tracing::debug!( 183 + bytes = bytes.len(), 184 + topic = ?self.topic, 185 + "CollabSession: broadcasting signed message" 186 + ); 146 187 147 188 self.sender 148 189 .broadcast(bytes.into()) ··· 155 196 /// Get the topic ID for this session. 156 197 pub fn topic(&self) -> TopicId { 157 198 self.topic 199 + } 200 + 201 + /// Add new peers to the gossip session. 202 + /// 203 + /// Use this to add peers discovered after initial subscription. 204 + /// The gossip layer will attempt to connect to these peers. 205 + pub async fn join_peers(&self, peers: Vec<EndpointId>) -> Result<(), SessionError> { 206 + if peers.is_empty() { 207 + return Ok(()); 208 + } 209 + tracing::info!( 210 + count = peers.len(), 211 + "CollabSession: joining additional peers" 212 + ); 213 + for peer in &peers { 214 + tracing::debug!(peer = %peer, "CollabSession: adding peer"); 215 + } 216 + self.sender 217 + .join_peers(peers) 218 + .await 219 + .map_err(|e| SessionError::Subscribe(Box::new(e)))?; 220 + Ok(()) 158 221 } 159 222 }