diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 1489a15..49a3a78 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -33,6 +33,7 @@ web = ["dioxus/web", "dioxus-primitives/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum", "dep:resvg", "dep:usvg", "dep:tiny-skia", "dep:textwrap", "dep:askama", "dep:fontdb"] +collab-worker = ["weaver-common/iroh"] [dependencies] diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-app/src/components/editor/worker.rs index 05f0462..84551b8 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-app/src/components/editor/worker.rs @@ -10,6 +10,7 @@ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use weaver_common::transport::PresenceSnapshot; /// Input messages to the editor worker. #[derive(Serialize, Deserialize, Debug, Clone)] @@ -35,6 +36,32 @@ pub enum WorkerInput { /// Editing CID if editing existing entry editing_cid: Option, }, + /// Start collab session (worker will spawn CollabNode) + StartCollab { + /// blake3 hash of resource URI (32 bytes) + topic: [u8; 32], + /// Bootstrap peer node IDs (z-base32 strings) + bootstrap_peers: Vec, + }, + /// Loro updates from local edits (forward to gossip) + BroadcastUpdate { + /// Loro update bytes + data: Vec, + }, + /// New peers discovered by main thread + AddPeers { + /// Node ID strings + peers: Vec, + }, + /// Local cursor position changed + BroadcastCursor { + /// Cursor position + position: usize, + /// Selection range if any + selection: Option<(usize, usize)>, + }, + /// Stop collab session + StopCollab, } /// Output messages from the editor worker. @@ -65,6 +92,24 @@ pub enum WorkerOutput { }, /// Error occurred. Error { message: String }, + /// Collab node ready, here's info for session record + CollabReady { + /// Node ID (z-base32 string) + node_id: String, + /// Relay URL for browser connectivity + relay_url: Option, + }, + /// Collab session joined successfully + CollabJoined, + /// Remote updates to merge into main doc + RemoteUpdates { + /// Loro update bytes + data: Vec, + }, + /// Presence state changed + PresenceUpdate(PresenceSnapshot), + /// Collab session ended + CollabStopped, } #[cfg(all(target_family = "wasm", target_os = "unknown"))] diff --git a/crates/weaver-common/Cargo.toml b/crates/weaver-common/Cargo.toml index d4c6edc..7d45fb3 100644 --- a/crates/weaver-common/Cargo.toml +++ b/crates/weaver-common/Cargo.toml @@ -9,6 +9,7 @@ publish = false default = ["dev"] dev = [] native = ["jacquard/dns"] +iroh = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-tickets"] [dependencies] n0-future = { workspace = true } @@ -34,14 +35,16 @@ mime-sniffer = "^0.1" # Real-time collaboration transport (iroh P2P) chrono = "0.4" blake3 = { version = "1", default-features = false, features = ["pure"] } -iroh = { version = "0.95", default-features = false } -iroh-gossip = { version = "0.95", default-features = false, features = ["net"] } postcard = "1.1.1" rand = "0.9.2" +web-time = "1" + +iroh = { version = "0.95", default-features = false, optional = true } +iroh-gossip = { version = "0.95", default-features = false, features = ["net"], optional = true } +iroh-tickets = { version = "0.2", optional = true } + getrandom = { version = "0.3", features = [] } -iroh-tickets = "0.2" ring = { version = "0.17", default-features = false } -web-time = "1" [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] diff --git a/crates/weaver-common/src/transport/discovery.rs b/crates/weaver-common/src/transport/discovery.rs index f9d26df..55421e5 100644 --- a/crates/weaver-common/src/transport/discovery.rs +++ b/crates/weaver-common/src/transport/discovery.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "iroh")] + //! Peer discovery via AT Protocol session records. //! //! Collaborators publish `sh.weaver.collab.session` records to their PDS diff --git a/crates/weaver-common/src/transport/messages.rs b/crates/weaver-common/src/transport/messages.rs index 38d7a88..d3a0395 100644 --- a/crates/weaver-common/src/transport/messages.rs +++ b/crates/weaver-common/src/transport/messages.rs @@ -1,6 +1,5 @@ //! Wire protocol for collaborative editing messages. -use iroh::{PublicKey, SecretKey, Signature}; use serde::{Deserialize, Serialize}; /// Messages exchanged between collaborators over gossip. @@ -65,78 +64,100 @@ impl CollabMessage { } } -/// A signed message wrapper for authenticated transport. -/// -/// Includes the sender's public key so receivers can verify without context. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignedMessage { - /// Sender's public key (also their EndpointId). - pub from: PublicKey, - /// The serialized TimestampedMessage (postcard bytes). - pub data: Vec, - /// Ed25519 signature over data. - pub signature: Signature, -} +// ============================================================================ +// Signed message wrapper (requires iroh for crypto) +// ============================================================================ -/// Versioned wire format with timestamp. -#[derive(Debug, Clone, Serialize, Deserialize)] -enum WireMessage { - V0 { timestamp: u64, message: CollabMessage }, -} +#[cfg(feature = "iroh")] +mod signed { + use super::*; + use iroh::{PublicKey, SecretKey, Signature}; + + /// A signed message wrapper for authenticated transport. + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct SignedMessage { + /// Sender's public key (also their EndpointId). + pub from: PublicKey, + /// The serialized TimestampedMessage (postcard bytes). + pub data: Vec, + /// Ed25519 signature over data. + pub signature: Signature, + } -/// A verified message with sender and timestamp info. -#[derive(Debug, Clone)] -pub struct ReceivedMessage { - /// Sender's public key. - pub from: PublicKey, - /// When the message was sent (micros since epoch). - pub timestamp: u64, - /// The decoded message. - pub message: CollabMessage, -} + /// Versioned wire format with timestamp. + #[derive(Debug, Clone, Serialize, Deserialize)] + enum WireMessage { + V0 { timestamp: u64, message: CollabMessage }, + } -/// Error type for signed message operations. -#[derive(Debug, thiserror::Error)] -pub enum SignedMessageError { - #[error("serialization failed: {0}")] - Serialization(#[from] postcard::Error), - #[error("signature verification failed")] - InvalidSignature, -} + /// A verified message with sender and timestamp info. + #[derive(Debug, Clone)] + pub struct ReceivedMessage { + /// Sender's public key. + pub from: PublicKey, + /// When the message was sent (micros since epoch). + pub timestamp: u64, + /// The decoded message. + pub message: CollabMessage, + } -impl SignedMessage { - /// Sign a message and encode to bytes for wire transmission. - pub fn sign_and_encode(secret_key: &SecretKey, message: &CollabMessage) -> Result, SignedMessageError> { - use web_time::SystemTime; - - let timestamp = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_micros() as u64; - let wire = WireMessage::V0 { timestamp, message: message.clone() }; - let data = postcard::to_stdvec(&wire)?; - let signature = secret_key.sign(&data); - let from = secret_key.public(); - let signed = Self { from, data, signature }; - Ok(postcard::to_stdvec(&signed)?) + /// Error type for signed message operations. + #[derive(Debug, thiserror::Error)] + pub enum SignedMessageError { + #[error("serialization failed: {0}")] + Serialization(#[from] postcard::Error), + #[error("signature verification failed")] + InvalidSignature, } - /// Decode from bytes and verify signature. - pub fn decode_and_verify(bytes: &[u8]) -> Result { - let signed: Self = postcard::from_bytes(bytes)?; - signed.from - .verify(&signed.data, &signed.signature) - .map_err(|_| SignedMessageError::InvalidSignature)?; - let wire: WireMessage = postcard::from_bytes(&signed.data)?; - let WireMessage::V0 { timestamp, message } = wire; - Ok(ReceivedMessage { - from: signed.from, - timestamp, - message, - }) + impl SignedMessage { + /// Sign a message and encode to bytes for wire transmission. + pub fn sign_and_encode( + secret_key: &SecretKey, + message: &CollabMessage, + ) -> Result, SignedMessageError> { + use web_time::SystemTime; + + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let wire = WireMessage::V0 { + timestamp, + message: message.clone(), + }; + let data = postcard::to_stdvec(&wire)?; + let signature = secret_key.sign(&data); + let from = secret_key.public(); + let signed = Self { + from, + data, + signature, + }; + Ok(postcard::to_stdvec(&signed)?) + } + + /// Decode from bytes and verify signature. + pub fn decode_and_verify(bytes: &[u8]) -> Result { + let signed: Self = postcard::from_bytes(bytes)?; + signed + .from + .verify(&signed.data, &signed.signature) + .map_err(|_| SignedMessageError::InvalidSignature)?; + let wire: WireMessage = postcard::from_bytes(&signed.data)?; + let WireMessage::V0 { timestamp, message } = wire; + Ok(ReceivedMessage { + from: signed.from, + timestamp, + message, + }) + } } } +#[cfg(feature = "iroh")] +pub use signed::{ReceivedMessage, SignedMessage, SignedMessageError}; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/weaver-common/src/transport/mod.rs b/crates/weaver-common/src/transport/mod.rs index 3a65568..3be9689 100644 --- a/crates/weaver-common/src/transport/mod.rs +++ b/crates/weaver-common/src/transport/mod.rs @@ -1,20 +1,34 @@ -//! Real-time collaboration transport layer using iroh P2P networking. +//! Real-time collaboration transport layer. //! -//! This module provides the infrastructure for real-time collaborative editing: -//! - `CollabNode`: iroh endpoint + gossip router (one per app instance) -//! - `CollabSession`: per-resource session management -//! - `CollabMessage`: wire protocol for CRDT updates, cursors, presence -//! - `discovery`: utilities for parsing NodeIds from session records +//! Core message types are always available. iroh-based networking +//! requires the `iroh` feature. -mod discovery; mod messages; +mod presence_types; + +#[cfg(feature = "iroh")] +mod discovery; +#[cfg(feature = "iroh")] mod node; +#[cfg(feature = "iroh")] mod presence; +#[cfg(feature = "iroh")] mod session; +// Always available - wire protocol +pub use messages::CollabMessage; +pub use presence_types::{CollaboratorInfo, PresenceSnapshot, RemoteCursorInfo}; + +// iroh feature - networking +#[cfg(feature = "iroh")] pub use discovery::{node_id_to_string, parse_node_id, DiscoveredPeer, DiscoveryError}; +#[cfg(feature = "iroh")] pub use iroh::EndpointId; -pub use messages::{CollabMessage, ReceivedMessage, SignedMessage, SignedMessageError}; +#[cfg(feature = "iroh")] +pub use messages::{ReceivedMessage, SignedMessage, SignedMessageError}; +#[cfg(feature = "iroh")] pub use node::{CollabNode, TransportError}; +#[cfg(feature = "iroh")] pub use presence::{Collaborator, PresenceTracker, RemoteCursor}; +#[cfg(feature = "iroh")] pub use session::{CollabSession, SessionError, SessionEvent, TopicId}; diff --git a/crates/weaver-common/src/transport/node.rs b/crates/weaver-common/src/transport/node.rs index 1d1644c..1809a1d 100644 --- a/crates/weaver-common/src/transport/node.rs +++ b/crates/weaver-common/src/transport/node.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "iroh")] + //! CollabNode - iroh endpoint with gossip router for real-time collaboration. use iroh::Endpoint; diff --git a/crates/weaver-common/src/transport/presence.rs b/crates/weaver-common/src/transport/presence.rs index 949d87b..9eaa85a 100644 --- a/crates/weaver-common/src/transport/presence.rs +++ b/crates/weaver-common/src/transport/presence.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "iroh")] + //! Presence tracking for collaborative editing sessions. //! //! Tracks active collaborators, their cursor positions, and display info. diff --git a/crates/weaver-common/src/transport/presence_types.rs b/crates/weaver-common/src/transport/presence_types.rs new file mode 100644 index 0000000..ad6242a --- /dev/null +++ b/crates/weaver-common/src/transport/presence_types.rs @@ -0,0 +1,54 @@ +//! Presence types for main thread rendering. +//! +//! These types use String node IDs instead of EndpointId, +//! allowing them to be used without the iroh feature. + +use serde::{Deserialize, Serialize}; + +/// A remote collaborator's cursor for rendering. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteCursorInfo { + /// Node ID as string (z-base32 encoded) + pub node_id: String, + /// Character offset in the document + pub position: usize, + /// Selection range (anchor, head) if any + pub selection: Option<(usize, usize)>, + /// Assigned colour (RGBA) + pub color: u32, +} + +/// Collaborator info for presence display. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CollaboratorInfo { + /// Node ID as string + pub node_id: String, + /// The collaborator's DID + pub did: String, + /// Display name for UI + pub display_name: String, + /// Assigned colour (RGBA) + pub color: u32, + /// Current cursor position (if known) + pub cursor_position: Option, + /// Current selection (if any) + pub selection: Option<(usize, usize)>, +} + +/// Presence update sent from worker to main thread. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PresenceSnapshot { + /// All known collaborators + pub collaborators: Vec, + /// Number of connected peers + pub peer_count: usize, +} + +impl Default for PresenceSnapshot { + fn default() -> Self { + Self { + collaborators: Vec::new(), + peer_count: 0, + } + } +} diff --git a/crates/weaver-common/src/transport/session.rs b/crates/weaver-common/src/transport/session.rs index f2b78fe..7fc9482 100644 --- a/crates/weaver-common/src/transport/session.rs +++ b/crates/weaver-common/src/transport/session.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "iroh")] + //! CollabSession - per-resource gossip session for real-time collaboration. use std::sync::Arc;