···9use std::sync::Arc;
10use weaver_common::transport::CollabNode;
11000000000000000000012/// Context state for the collaboration node.
13///
14/// This is provided as a Dioxus context and can be accessed by editor components
···39#[component]
40pub fn CollabProvider(children: Element) -> Element {
41 let mut collab_ctx = use_signal(CollabContext::default);
04243 // Spawn the CollabNode on mount
44 let _spawn_result = use_resource(move || async move {
···62 }
63 });
6465- // Provide the context
66 use_context_provider(|| collab_ctx);
06768 rsx! { {children} }
69}
···74pub fn CollabProvider(children: Element) -> Element {
75 // On server/native, provide an empty context (collab happens in browser)
76 let collab_ctx = use_signal(CollabContext::default);
077 use_context_provider(|| collab_ctx);
078 rsx! { {children} }
79}
80···91 let ctx = use_context::<Signal<CollabContext>>();
92 ctx.read().node.is_some()
93}
000000
···9use std::sync::Arc;
10use weaver_common::transport::CollabNode;
1112+/// 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+31/// Context state for the collaboration node.
32///
33/// This is provided as a Dioxus context and can be accessed by editor components
···58#[component]
59pub fn CollabProvider(children: Element) -> Element {
60 let mut collab_ctx = use_signal(CollabContext::default);
61+ let debug_state = use_signal(CollabDebugState::default);
6263 // Spawn the CollabNode on mount
64 let _spawn_result = use_resource(move || async move {
···82 }
83 });
8485+ // Provide the contexts
86 use_context_provider(|| collab_ctx);
87+ use_context_provider(|| debug_state);
8889 rsx! { {children} }
90}
···95pub fn CollabProvider(children: Element) -> Element {
96 // On server/native, provide an empty context (collab happens in browser)
97 let collab_ctx = use_signal(CollabContext::default);
98+ let debug_state = use_signal(CollabDebugState::default);
99 use_context_provider(|| collab_ctx);
100+ use_context_provider(|| debug_state);
101 rsx! { {children} }
102}
103···114 let ctx = use_context::<Signal<CollabContext>>();
115 ctx.read().node.is_some()
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+}
···1//! Wire protocol for collaborative editing messages.
23+use iroh::{PublicKey, SecretKey, Signature};
4use serde::{Deserialize, Serialize};
56/// Messages exchanged between collaborators over gossip.
···54}
5556impl CollabMessage {
57+ /// Serialize message to postcard bytes for wire transmission.
58 pub fn to_bytes(&self) -> Result<Vec<u8>, postcard::Error> {
59 postcard::to_stdvec(self)
60 }
6162+ /// Deserialize message from postcard bytes.
63 pub fn from_bytes(bytes: &[u8]) -> Result<Self, postcard::Error> {
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+ })
137 }
138}
139
+1-4
crates/weaver-common/src/transport/mod.rs
···1415pub use discovery::{node_id_to_string, parse_node_id, DiscoveredPeer, DiscoveryError};
16pub use iroh::EndpointId;
17-pub use messages::CollabMessage;
18pub use node::{CollabNode, TransportError};
19pub use presence::{Collaborator, PresenceTracker, RemoteCursor};
20pub 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";
···1415pub use discovery::{node_id_to_string, parse_node_id, DiscoveredPeer, DiscoveryError};
16pub use iroh::EndpointId;
17+pub use messages::{CollabMessage, ReceivedMessage, SignedMessage, SignedMessageError};
18pub use node::{CollabNode, TransportError};
19pub use presence::{Collaborator, PresenceTracker, RemoteCursor};
20pub use session::{CollabSession, SessionError, SessionEvent, TopicId};
000
+51-5
crates/weaver-common/src/transport/node.rs
···3use iroh::Endpoint;
4use iroh::EndpointId;
5use iroh::SecretKey;
6-use iroh_gossip::net::Gossip;
7use miette::Diagnostic;
8use std::sync::Arc;
9-10-use super::WEAVER_GOSSIP_ALPN;
1112/// Error type for transport operations
13#[derive(Debug, thiserror::Error, Diagnostic)]
···48 // In native, this can do direct P2P with relay fallback
49 let endpoint = Endpoint::builder()
50 .secret_key(secret_key.clone())
51- .alpns(vec![WEAVER_GOSSIP_ALPN.to_vec()])
52 .bind()
53 .await
54 .map_err(|e| TransportError::Bind(Box::new(e)))?;
···5859 // Build router to dispatch incoming connections by ALPN
60 let router = iroh::protocol::Router::builder(endpoint.clone())
61- .accept(WEAVER_GOSSIP_ALPN, gossip.clone())
62 .spawn();
6364 tracing::info!(node_id = %endpoint.id(), "CollabNode started");
···97 /// Get a clone of the secret key (for session persistence if needed).
98 pub fn secret_key(&self) -> SecretKey {
99 self.secret_key.clone()
000000000000000000000000000000000000000000000000100 }
101}
···3use iroh::Endpoint;
4use iroh::EndpointId;
5use iroh::SecretKey;
6+use iroh_gossip::net::{GOSSIP_ALPN, Gossip};
7use miette::Diagnostic;
8use std::sync::Arc;
00910/// Error type for transport operations
11#[derive(Debug, thiserror::Error, Diagnostic)]
···46 // In native, this can do direct P2P with relay fallback
47 let endpoint = Endpoint::builder()
48 .secret_key(secret_key.clone())
49+ .alpns(vec![GOSSIP_ALPN.to_vec()])
50 .bind()
51 .await
52 .map_err(|e| TransportError::Bind(Box::new(e)))?;
···5657 // Build router to dispatch incoming connections by ALPN
58 let router = iroh::protocol::Router::builder(endpoint.clone())
59+ .accept(GOSSIP_ALPN, gossip.clone())
60 .spawn();
6162 tracing::info!(node_id = %endpoint.id(), "CollabNode started");
···95 /// Get a clone of the secret key (for session persistence if needed).
96 pub fn secret_key(&self) -> SecretKey {
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())
146 }
147}
+93-30
crates/weaver-common/src/transport/session.rs
···9use n0_future::boxed::BoxStream;
10use n0_future::stream;
1112-use super::{CollabMessage, CollabNode};
1314/// Topic ID for a gossip session - derived from resource URI.
15pub type TopicId = iroh_gossip::TopicId;
···57pub struct CollabSession {
58 topic: TopicId,
59 sender: GossipSender,
60- #[allow(dead_code)]
61 node: Arc<CollabNode>,
62}
63···79 node: Arc<CollabNode>,
80 topic: TopicId,
81 bootstrap_peers: Vec<EndpointId>,
82- ) -> Result<(Self, BoxStream<SessionEvent>), SessionError> {
000000000083 // Subscribe to the gossip topic
84 let (sender, receiver) = node
85 .gossip()
86- .subscribe(topic, bootstrap_peers)
87 .await
88 .map_err(|e| SessionError::Subscribe(Box::new(e)))?
89 .split();
009091 let session = Self {
92 topic,
···101 }
102103 /// 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 {
106 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");
0000000000000000000000119 continue;
120 }
121- },
122- Event::Lagged => {
123- tracing::warn!("gossip receiver lagged, some messages may be lost");
0000124 continue;
125 }
126- };
127- return Some((session_event, receiver));
128 }
129- Some(Err(e)) => {
130- tracing::warn!(?e, "gossip receiver error");
131 continue;
132 }
133- None => return None,
134- }
135 }
136 });
137138 Box::pin(stream)
139 }
140141- /// Broadcast a message to all peers in the session.
142 pub async fn broadcast(&self, message: &CollabMessage) -> Result<(), SessionError> {
143- let bytes = message
144- .to_bytes()
145 .map_err(|e| SessionError::Broadcast(Box::new(e)))?;
000000146147 self.sender
148 .broadcast(bytes.into())
···155 /// Get the topic ID for this session.
156 pub fn topic(&self) -> TopicId {
157 self.topic
0000000000000000000000158 }
159}
···9use n0_future::boxed::BoxStream;
10use n0_future::stream;
1112+use super::{CollabMessage, CollabNode, SignedMessage};
1314/// Topic ID for a gossip session - derived from resource URI.
15pub type TopicId = iroh_gossip::TopicId;
···57pub struct CollabSession {
58 topic: TopicId,
59 sender: GossipSender,
060 node: Arc<CollabNode>,
61}
62···78 node: Arc<CollabNode>,
79 topic: TopicId,
80 bootstrap_peers: Vec<EndpointId>,
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+92 // Subscribe to the gossip topic
93 let (sender, receiver) = node
94 .gossip()
95+ .subscribe_and_join(topic, bootstrap_peers)
96 .await
97 .map_err(|e| SessionError::Subscribe(Box::new(e)))?
98 .split();
99+100+ tracing::info!("CollabSession: subscribed to gossip topic");
101102 let session = Self {
103 topic,
···112 }
113114 /// Convert gossip receiver into a stream of session events.
115+ fn event_stream(receiver: GossipReceiver) -> BoxStream<Result<SessionEvent, SessionError>> {
116+ let stream = stream::try_unfold(receiver, |mut receiver| async move {
117 loop {
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+ );
152 continue;
153 }
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");
161 continue;
162 }
163+ }
0164 }
165+ Event::Lagged => {
166+ tracing::warn!("gossip receiver lagged, some messages may be lost");
167 continue;
168 }
169+ };
170+ break Ok(Some((session_event, receiver)));
171 }
172 });
173174 Box::pin(stream)
175 }
176177+ /// Broadcast a signed message to all peers in the session.
178 pub async fn broadcast(&self, message: &CollabMessage) -> Result<(), SessionError> {
179+ let bytes = SignedMessage::sign_and_encode(&self.node.secret_key(), message)
0180 .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+ );
187188 self.sender
189 .broadcast(bytes.into())
···196 /// Get the topic ID for this session.
197 pub fn topic(&self) -> TopicId {
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(())
221 }
222}