use iroh::{EndpointId, EndpointAddr}; use bytes::Bytes; use thiserror::Error; use tokio::sync::mpsc::Receiver; use async_trait::async_trait; use serde::{Serialize, Deserialize}; use std::str::FromStr; #[derive(Error, Debug)] pub enum NetError { #[error("Failed to initialize network: {0}")] Init(String), #[error("Failed to connect/dial peer: {0}")] Connection(String), #[error("Gossip swarm error: {0}")] Gossip(String), #[error("Serialization / Deserialization error: {0}")] Serde(String), #[error("Invalid ticket: {0}")] InvalidTicket(String), #[error("Other network error: {0}")] Other(String), } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PeerState { pub name: String, pub is_muted: bool, pub addr: iroh::EndpointAddr, /// When this peer is sharing their screen, the pixelpass relay ticket a /// viewer needs to watch it; `None` when not sharing. Riding presence means /// the existing gossip re-announce delivers it to late joiners for free, and /// a `PeerUpdated` fires automatically on share start/stop. Defaulted so /// older configs / peers that predate the field still deserialize. #[serde(default)] pub sharing: Option, /// This peer's chosen avatar (W4). Rides presence so it reaches everyone /// (incl. late joiners) with no server, like `sharing`. Defaulted so older /// peers/configs that predate the field still deserialize (→ monogram). #[serde(default)] pub avatar: crate::avatar::Avatar, } #[derive(Debug, Clone)] pub enum RoomEvent { PeerJoined(EndpointId, PeerState), /// A peer left *gracefully* (it broadcast a `Leave`). Evict it immediately. PeerLeft(EndpointId), PeerUpdated(EndpointId, PeerState), /// A peer's gossip neighbour link dropped without a graceful `Leave` (network /// blip, crash, walked out of range). This is transient: the audio supervisor /// keeps redialing, so the core marks the peer "reconnecting" and only evicts /// it if the link hasn't recovered within the reconnect grace window. Distinct /// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the /// reconnect path the way it used to. PeerConnectionLost(EndpointId), /// A peer sent a room text-chat message. Carries the sender's id, their /// display name (embedded so it shows even without a presence entry), the /// text, and a sender-stamped millisecond timestamp. ChatMessage { from: EndpointId, name: String, text: String, ts: u64, /// Optional file attachment descriptor; the bytes are fetched off-gossip /// on the file plane. Already filename-sanitized + size-capped on ingest. attachment: Option, }, } /// Transport-level link state for a peer, surfaced so the UI can show when a /// peer's audio connection is being (re)established versus actually carrying /// audio. Distinct from `RoomEvent`: a peer can be present in the gossip room /// while its audio link is momentarily down and reconnecting. #[derive(Debug, Clone)] pub enum ConnEvent { /// No live audio link yet — initial connect or reconnecting after a drop. Connecting(EndpointId), /// A live audio link is established and carrying datagrams. Connected(EndpointId), /// The peer closed its link *gracefully* (an explicit QUIC application close, /// which only happens on an intentional leave/quit — a network drop yields a /// timeout, not this). Distinct from a transient drop so the core can evict /// the peer immediately instead of waiting out the reconnect grace window. /// This is the prompt, reliable leave signal; the gossip `Leave` is too slow. Left(EndpointId), } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PeerSpeakTicket { pub host_addr: iroh::EndpointAddr, pub topic_id: [u8; 32], /// Cosmetic room label (W7), chosen by the creator and carried in the ticket /// so everyone who joins via it inherits the same label and reports "in /// HangOut" in presence. Untrusted on receipt (it rides a peer-shared ticket) /// — sanitize before display. `#[serde(default)]` keeps older, pre-label /// tickets parseable (they decode to an empty label). #[serde(default)] pub name: String, } impl PeerSpeakTicket { /// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our /// OWN address while keeping its room `topic_id` AND its cosmetic `name`, so /// any member can hand out a working door that bootstraps off themselves — the /// mechanism that lets a room outlive its creator. A no-op (returns the input /// unchanged) if the string can't be parsed. Re-stamping with the same address /// is idempotent. pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String { match ticket_str.parse::() { Ok(t) => { PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name } .to_string() } Err(_) => ticket_str.to_string(), } } /// The cosmetic room label embedded in a ticket string, or `""` if the ticket /// can't be parsed or carries no label. Pure; used to label the gathering both /// in the room UI and in the presence we report to friends. pub fn label_of(ticket_str: &str) -> String { ticket_str.parse::().map(|t| t.name).unwrap_or_default() } /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket /// can't be parsed. Pure; used as the stable room identity for de-duplicating /// the recents list (the host address and label change between members/sessions, /// but the topic uniquely identifies the gathering). pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> { ticket_str.parse::().ok().map(|t| t.topic_id) } } impl std::fmt::Display for PeerSpeakTicket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // serde_json on a struct of String/[u8;32] fields is infallible in practice, // but Display can't surface an error, so fall back to an empty ticket body. let serialized = serde_json::to_vec(self).unwrap_or_default(); let encoded = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, &serialized, ); f.write_str(&encoded) } } impl FromStr for PeerSpeakTicket { type Err = NetError; fn from_str(s: &str) -> Result { let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s) .map_err(|e| NetError::InvalidTicket(e.to_string()))?; let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded) .map_err(|e| NetError::InvalidTicket(e.to_string()))?; Ok(ticket) } } #[async_trait] pub trait NetworkTransport: Send + Sync { /// Establish (or ensure) a connection to a peer and set up its send path. /// Idempotent; safe to call again for an already-connected peer — a repeat /// call refreshes the retained dial address, so calling it again when a peer /// re-announces (e.g. on a new address) keeps the dialer able to re-reach it. /// The full `EndpointAddr` (relay + direct addrs) is retained so reconnects /// dial it directly instead of depending on a lookup that a transient gossip /// `Leave`/`NeighborDown` may have purged. async fn connect_peer(&self, addr: iroh::EndpointAddr); /// Tear down the connection and send path for a peer that has left. async fn disconnect_peer(&self, peer_id: EndpointId); /// Fan a single audio datagram out to every connected peer. Non-blocking: /// per-peer queues drop the oldest-pending frame when full, so a slow link /// can never stall the capture/encode thread. Callable from any thread. fn broadcast(&self, data: Bytes); /// Subscribes to incoming datagrams from any peer. async fn receive_datagrams(&self) -> Result, NetError>; /// Subscribes to per-peer link-state changes (connecting / connected). async fn subscribe_conn_events(&self) -> Result, NetError>; } #[async_trait] pub trait RoomState: Send + Sync { /// Joins a room using a gossip ticket string and announces our state. /// /// `extra_bootstrap` are additional peer addresses to dial when entering the /// gossip swarm, on top of the ticket's host. This is what lets the ROOM /// CREATOR rejoin a room they left: their own ticket lists only themselves as /// host, so without retained peers they'd have no dial target and never /// re-enter the swarm (bug A8). Callers with no prior peers pass `vec![]`. async fn join( &self, ticket: &str, self_state: PeerState, extra_bootstrap: Vec, ) -> Result<(), NetError>; /// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it. async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>; /// Ask the active gossip topic to connect to retained peer addresses without /// leaving or replacing the subscription. This is a recovery primitive only: /// it does not add peers to the authenticated room roster. A peer becomes /// active only after its normal signed `Announce` is received and verified. async fn rebootstrap_peers(&self, peers: Vec) -> Result<(), NetError>; /// Remove a peer from the authenticated live roster before background /// recovery. This only revokes membership; a fresh verified `Announce` is /// required to add the peer again. fn mark_peer_disconnected(&self, peer_id: EndpointId); /// Broadcasts a room text-chat message authored by us (our display name is /// taken from the current self-state), optionally carrying a file attachment /// descriptor whose bytes are served separately on the file plane. async fn send_chat( &self, text: String, attachment: Option, ) -> Result<(), NetError>; /// Leaves the room and announces departure. async fn leave(&self) -> Result<(), NetError>; /// Returns a list of currently active peers in the room. fn active_peers(&self) -> Vec<(EndpointId, PeerState)>; /// Subscribes to room events (peer joined, peer left, peer updated). async fn subscribe_events(&self) -> Result, NetError>; } pub mod iroh_impl; pub mod gossip; #[cfg(test)] mod tests { use super::*; use iroh::{SecretKey, EndpointAddr}; fn sample_peer_state() -> PeerState { let secret = SecretKey::generate(); let public = secret.public(); let addr = EndpointAddr::from(public); PeerState { name: "TestPeer".to_string(), is_muted: false, addr, sharing: None, avatar: crate::avatar::Avatar::default(), } } #[test] fn test_ticket_round_trip() { let state = sample_peer_state(); let topic_id = [7u8; 32]; let original_ticket = PeerSpeakTicket { host_addr: state.addr.clone(), topic_id, name: "HangOut".to_string(), }; let ticket_str = original_ticket.to_string(); let parsed_ticket = ticket_str.parse::().unwrap(); assert_eq!(parsed_ticket.host_addr.id, original_ticket.host_addr.id); assert_eq!(parsed_ticket.topic_id, original_ticket.topic_id); assert_eq!(parsed_ticket.name, "HangOut"); } #[test] fn test_ticket_label_helpers_and_backcompat() { let host = SecretKey::generate().public(); let topic_id = [3u8; 32]; // A labelled ticket: restamp keeps the label, label_of reads it. let labelled = PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } .to_string(); assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut"); let member = SecretKey::generate().public(); let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member)); assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut"); // An unparseable ticket has no label rather than panicking. assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), ""); // topic_of reads the room identity, and returns None for a bad ticket. assert_eq!(PeerSpeakTicket::topic_of(&labelled), Some(topic_id)); assert_eq!(PeerSpeakTicket::topic_of(&restamped), Some(topic_id)); assert_eq!(PeerSpeakTicket::topic_of("not-a-ticket"), None); // Backward-compat: a pre-label ticket JSON (no `name` key) still parses, // defaulting the label to "". let legacy_json = serde_json::json!({ "host_addr": serde_json::to_value(EndpointAddr::from(host)).unwrap(), "topic_id": topic_id.to_vec(), }); let legacy_str = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, serde_json::to_vec(&legacy_json).unwrap(), ); let parsed = legacy_str.parse::().unwrap(); assert_eq!(parsed.name, ""); } #[test] fn test_malformed_rejection() { // empty string let res1 = "".parse::(); assert!(matches!(res1, Err(NetError::InvalidTicket(_)))); // non-base64 garbage let res2 = "!!!not base64!!!".parse::(); assert!(matches!(res2, Err(NetError::InvalidTicket(_)))); // valid URL-safe-base64 that decodes to non-JSON bytes let bad_json = b"hello world"; let encoded = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json, ); let res3 = encoded.parse::(); assert!(matches!(res3, Err(NetError::InvalidTicket(_)))); } #[test] fn test_restamp_swaps_addr_keeps_topic() { // A ticket "from" the host, then re-stamped by another member. let host = SecretKey::generate().public(); let member = SecretKey::generate().public(); let topic_id = [42u8; 32]; let original = PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } .to_string(); let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member)); let restamped = restamped_str.parse::().unwrap(); // Same room (and label), but the door now points at the member, not the host. assert_eq!(restamped.topic_id, topic_id); assert_eq!(restamped.host_addr.id, member); assert_ne!(restamped.host_addr.id, host); assert_eq!(restamped.name, "HangOut"); } #[test] fn test_restamp_is_idempotent_for_same_addr() { let me = SecretKey::generate().public(); let topic_id = [7u8; 32]; let mine = PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() } .to_string(); // Re-stamping my own ticket with my own addr changes nothing. assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine); } #[test] fn test_restamp_passes_through_unparseable() { let me = SecretKey::generate().public(); // A malformed ticket is returned unchanged (the join will fail anyway). assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); } #[test] fn test_peer_state_serde_round_trip() { let original = sample_peer_state(); let serialized = serde_json::to_string(&original).unwrap(); let deserialized: PeerState = serde_json::from_str(&serialized).unwrap(); assert_eq!(original, deserialized); } }