//! What joining a session means on any transport, shared by the native guest ([`crate::guest`]) //! and the browser client: the join settings, how a connection ended and what follows, and taking //! in what the host sends. use std::sync::Mutex; use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Peer, Resolve, ResolveTeam, Resume, ServerMsg}; use figcraft_engine::live::{Event, MAX_PEERS, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::{Invite, Lookup}; use crate::limits::NetLimits; /// What the host tells everyone when it stops (the WebSocket close reason). pub const ENDED: &str = "The host ended the session"; /// What a server tells everyone when it stops (they reconnect once it is back). pub const RESTARTING: &str = "The server is restarting"; /// WebSocket close codes hosts use: the app's session ended (going away), you were refused /// (policy), or the server is restarting (service restart: try again). pub(crate) const CLOSE_AWAY: u16 = 1001; pub(crate) const CLOSE_POLICY: u16 = 1008; pub(crate) const CLOSE_RESTART: u16 = 1012; /// How a guest connects and reconnects. #[derive(Clone, Debug)] pub struct GuestConfig { pub invite: Invite, /// What to ask `invite.server` first (a join code, or the team link of `invite.file`): its /// answer fills in the invite's file and token ([`resolved`]). pub lookup: Option, /// Your name (empty: the host picks one). pub name: String, pub limits: NetLimits, pub connect_timeout: Duration, /// The pause before the first reconnect; it doubles each time up to `max_backoff`. pub backoff: Duration, pub max_backoff: Duration, /// Reconnect attempts in a row before the longer pause (`round_pause`), after which it tries /// as many again: a connection that was live is never given up on by itself. pub attempts: u32, pub round_pause: Duration, /// Join as the client you were (an app that starts over after a crash): the first hello /// carries it, and a server that still knows the id gives it back. pub resume: Option, } impl GuestConfig { pub fn new(invite: Invite, name: String) -> Self { Self { invite, lookup: None, name, limits: NetLimits::default(), connect_timeout: Duration::from_secs(5), backoff: Duration::from_millis(500), max_backoff: Duration::from_secs(10), attempts: 12, round_pause: Duration::from_secs(60), resume: None, } } /// The pause before reconnect `attempt` (from 1). pub(crate) fn backoff(&self, attempt: u32) -> Duration { self.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(self.max_backoff) } /// The pause before reconnect attempt `attempt` (from 1): growing within a round of /// [`attempts`](Self::attempts), and [`round_pause`](Self::round_pause) between rounds. pub(crate) fn pause(&self, attempt: u32) -> Duration { let round = self.attempts.max(1); let into_round = attempt.saturating_sub(1) % round; if into_round == 0 && attempt > round { self.round_pause } else { self.backoff(into_round + 1) } } } /// How a connection ended. pub(crate) enum Outcome { /// Dropped: try again. Retry(String), /// The host said no (bad token, full…): final. Refused(String), /// The host ended the session: final. Ended(String), /// It can't go on, for this reason as it is shown: final. Failed(String), /// You left. Left, } /// The server couldn't be asked about a join code, or said no: final, in words for the person who /// typed it (the server's own for a code it doesn't know). pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { Outcome::Failed(match outcome { // A server from before join codes (or team lists) can't read the question. Outcome::Refused(why) if why.contains("couldn't be read") && cfg.lookup == Some(Lookup::Team) => { format!("{} has no team list (it runs an older version): ask for the invite link instead", cfg.invite.server.display()) } Outcome::Refused(why) if why.contains("couldn't be read") => { format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display()) } Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why, Outcome::Retry(why) => format!("Couldn't reach the server ({why})"), Outcome::Left => return Outcome::Left, }) } /// The host sent a message over this app's size limit (the file's welcome, usually): final, and /// not the host's doing. pub(crate) fn too_large(limits: &NetLimits) -> Outcome { Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) } /// The first message that asks `invite`'s server what `lookup` stands for (answered with /// [`ServerMsg::Resolved`]). pub(crate) fn ask(invite: &Invite, lookup: &Lookup) -> ClientMsg { let client_version = message::client_version(); match lookup { Lookup::Code(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version, code: code.clone() }), Lookup::Team => ClientMsg::ResolveTeam(ResolveTeam { protocol: PROTOCOL, client_version, file: invite.file.clone() }), } } /// The server's answer to the join code (or about the team list's file): the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { let asked_file = cfg.lookup != Some(Lookup::Team) || file == cfg.invite.file; if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) || !asked_file { return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into())); } cfg.lookup = None; (cfg.invite.file, cfg.invite.token) = (file, token); Ok(()) } /// What a close frame from the host means. pub(crate) fn closed(code: u16, reason: &str) -> Outcome { match code { CLOSE_AWAY => Outcome::Ended(if reason.is_empty() { ENDED.to_string() } else { reason.to_string() }), CLOSE_POLICY => Outcome::Refused(reason.to_string()), CLOSE_RESTART => Outcome::Retry(if reason.is_empty() { RESTARTING.to_string() } else { reason.to_string() }), _ => Outcome::Retry("the host closed the connection".into()), } } /// What a guest does after a connection ended. pub(crate) enum Next { /// Done: tell the UI this (nothing when you left). Boxed: an event can be large (a welcome), /// and this one is only ever a status or the end of the session. Stop(Option>), /// Connect again after this pause (the UI has been told: `Reconnecting`). Reconnect(Duration), } /// What a server refuses for good, whenever you ask: the link, the file or your place in it is /// gone, or the two versions can't talk. Anything else a server refuses a session with (it is /// busy, a limit was hit, the file couldn't be read just now) may pass, and servers of any version /// say so in these words, so a reason not listed here means try again. const FINAL_REFUSALS: [&str; 12] = [ "isn't valid", "isn't on the server", "was removed from the server", "were removed from this session", "no longer accepts connections", "doesn't accept connections", "wrong session token", "isn't supported", "couldn't be read", "message too large", "this version can't read", "sent a text message", ]; /// Whether `reason`, from a server refusing a session that was live, is for good. pub(crate) fn final_refusal(reason: &str) -> bool { FINAL_REFUSALS.iter().any(|f| reason.contains(f)) } /// After `outcome`: the end of the session is final, and so are a refusal for good and a first /// join that can't reach the host. A live connection that dropped or was turned away for now tries /// again until you leave: with growing pauses, and a longer one between rounds. pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next { let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason })))); let outcome = match outcome { Outcome::Refused(why) if was_live && !final_refusal(&why) => Outcome::Retry(why), o => o, }; match outcome { Outcome::Left => Next::Stop(None), Outcome::Refused(why) => failed(format!("The host refused: {why}")), Outcome::Failed(why) => failed(why), Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))), Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")), Outcome::Retry(why) => { *attempt = attempt.saturating_add(1); log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}"); inbox.push(Event::Status(Status::Reconnecting { attempt: *attempt })); Next::Reconnect(cfg.pause(*attempt)) } } } /// Who this guest is in the session and the last sequence number it got (from the welcome and the /// batches since): what a reconnect resumes with. The engine applies every message it got, in /// order, before the next welcome, so its sync client is at that sequence number by then. (Shared /// by the native guest's reader and writer threads.) pub(crate) type Resuming = Mutex>; /// The hello for a new connection: back after a drop, the same client catching up from where it /// was. pub(crate) fn hello(cfg: &GuestConfig, resume: &Resuming) -> ClientMsg { let mut hello = Client::hello(&cfg.invite.file, &cfg.invite.token, &cfg.name, ""); if let (ClientMsg::Hello(h), Some(r)) = (&mut hello, resume.lock().ok().and_then(|r| *r)) { h.resume = Some(r); } hello } /// What a connection knows of the session (to tell the UI who's here). #[derive(Default)] pub(crate) struct Seen { info: Option, peers: Vec, } /// `p` with its name and colour cut to what the UI shows: the host is untrusted, and the roster is /// copied whenever someone's identity is updated. fn bounded(mut p: Peer) -> Peer { p.name = p.name.chars().take(MAX_PEER_NAME).collect(); p.color = p.color.chars().take(MAX_COLOR).collect(); p } /// The most characters of a colour (`#rrggbb` and a few spellings more) kept for someone. const MAX_COLOR: usize = 32; /// The most characters of a name kept for someone. const MAX_PEER_NAME: usize = 256; /// What taking in one message from the host means for the connection. pub(crate) enum Taken { Continue, /// The welcome: the connection is live (send the presence that waited). Welcomed, /// The connection should end. End(Outcome), } /// Take in one message from the host: events for the UI go to `inbox`, and `resume` follows the /// session. On the welcome, `welcomed` runs before the UI hears of it: what the sync client sends /// in answer must go out on this connection. pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { // A compressed document is read here, within the size the welcome could have had unpacked. match message::unpack(&mut m, cfg.limits.max_server_message) { Ok(()) => {} Err(figcraft_collab::Error::TooLarge(_)) => return Taken::End(too_large(&cfg.limits)), Err(e) => return Taken::End(Outcome::Refused(format!("the host sent a document this version can't read ({e})"))), } match m { ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => { if let Ok(mut r) = resume.lock() { *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). let peers: Vec = peers.iter().take(MAX_PEERS).cloned().map(bounded).collect(); let inv = &cfg.invite; let info = SessionInfo { role: Role::Guest, me: client_id, name: cfg.name.clone(), color: String::new(), file_name: String::new(), pages: Vec::new(), link: inv.link(), url: inv.url(), address: inv.server.display(), network: !inv.loopback(), access, view: None, }; seen.peers = peers.clone(); // The sync client keeps the roster of the welcome it is handed: the bounded one. if let ServerMsg::Welcome { peers: sent, .. } = &mut m { sent.clone_from(&peers); } seen.info = Some(info.clone()); welcomed(); inbox.push(Event::Welcome { info, peers }); inbox.push(Event::Sync(m)); return Taken::Welcomed; } ServerMsg::PeerJoined(p) => match (seen.info.as_mut(), bounded(p)) { // The host tells you how you are shown. (Some(info), p) if p.client_id == info.me => { info.name = p.name; info.color = p.color; inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() }); } (_, p) => { match seen.peers.iter().position(|q| q.client_id == p.client_id) { Some(i) => { seen.peers.remove(i); } None if seen.peers.len() >= MAX_PEERS => { log::warn!("live session: {MAX_PEERS} people already here; leaving out another"); return Taken::Continue; } None => {} } seen.peers.push(p.clone()); inbox.push(Event::PeerJoined(p)); } }, ServerMsg::PeerLeft { client_id } => { seen.peers.retain(|q| q.client_id != client_id); inbox.push(Event::PeerLeft(client_id)); } ServerMsg::Presence { client_id, presence } => { if seen.info.as_ref().is_some_and(|i| i.me != client_id) { inbox.presence(client_id, presence); } } ServerMsg::Refused { reason } => return Taken::End(Outcome::Refused(reason)), ServerMsg::Changes(ref acc) => { if let Ok(mut r) = resume.lock() && let Some(r) = r.as_mut() { r.seq = acc.seq; } inbox.push(Event::Sync(m)); } ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)), // Checked again by the engine (the host is as untrusted as any peer). ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)), ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)), ServerMsg::Reported { error } => inbox.push(Event::Reported(error)), // Answers for managing and library connections, and to a join code: not this one's. ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {} } Taken::Continue }