diff --git a/Cargo.lock b/Cargo.lock index 4c106e2..0b7e674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,10 +1453,15 @@ dependencies = [ "figcraft-doc", "figcraft-engine", "getrandom 0.4.3", + "js-sys", "log", "proptest", "serde_json", "tungstenite", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-registry", ] [[package]] @@ -5267,6 +5272,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/README.md b/README.md index 38fadc1..21dd094 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). -- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow (undo is not per person yet). See [`docs/collab-protocol.md`](docs/collab-protocol.md). +- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow (undo is not per person yet). Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. - **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images. diff --git a/ROADMAP.md b/ROADMAP.md index 86c4608..dcd2613 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged #101–#103: plugins and the multiplayer core; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h). | Features (depth) | 57% (core editor 67%) | 78–146 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 12% (core in; live sessions with document sync and cursors, preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | +| Collaboration | 12% (core in; live sessions with document sync and cursors, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) | @@ -123,7 +123,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. 4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h. -5. Multiplayer (M9): the browser client and sharing on top of the live sessions (transport, hosting, invite links, document sync, presence, per-user undo and reconnect with offline edits landed): 45–90 h. +5. Multiplayer (M9): sharing and a standalone server with TLS on top of the live sessions (transport, hosting, invite links that open the app, document sync, presence, per-user undo, reconnect with offline edits and the browser client landed): 45–90 h. ## Progress log @@ -152,6 +152,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | | 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index f741414..587fd60 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -7,7 +7,11 @@ //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); //! - no native file dialogs yet: the session starts with one new document; -//! - links open in a new browser tab. +//! - links open in a new browser tab; +//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't +//! host one. A page served over `https://` can only reach sessions on the same computer +//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to +//! join from other machines. //! //! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 275cb72..0420f3b 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -36,8 +36,10 @@ pub fn start() { } let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); - // Live sessions say they need the desktop app until the browser client lands. - session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(|| {})))); + // Live sessions: the browser joins sessions a desktop app hosts; whatever arrives + // on the socket wakes the page for a frame. + let ctx = cc.egui_ctx.clone(); + session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let fig = FigApp::new(session, Box::new(WebServices { reduce_motion })); Ok(Box::new(WebShell { fig })) }), diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs new file mode 100644 index 0000000..2e3099c --- /dev/null +++ b/apps/figcraft/src/args.rs @@ -0,0 +1,63 @@ +//! The command line: `figcraft [--control ] [files…] [figcraft://join?…]`. An invite link +//! (what the system passes when a `figcraft://` link is opened) joins that session once the window +//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the +//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). + +/// What the app was started with. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Args { + /// `--control `: the JSON-lines control server. + pub control: Option, + pub files: Vec, + /// The last invite link given. + pub join: Option, +} + +/// An invite link rather than a file (`figcraft:` links, in any case). +pub fn is_invite(arg: &str) -> bool { + arg.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("figcraft:")) +} + +/// The path a local `file://` URL names (`%XX` escapes decoded); `None` for anything else. +pub fn file_url_path(arg: &str) -> Option { + let rest = arg.get(..7).filter(|s| s.eq_ignore_ascii_case("file://")).and(arg.get(7..))?; + let path = rest.strip_prefix("localhost").unwrap_or(rest); + if !path.starts_with('/') { + return None; + } + let bytes = path.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if b == b'%' { + let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(b); + i += 1; + } + } + let path = String::from_utf8(out).ok()?; + // `file:///C:/…` on Windows. + let drive = matches!(path.as_bytes().get(1..3), Some([l, b':']) if l.is_ascii_alphabetic()); + Some(if cfg!(windows) && drive { path.get(1..).unwrap_or_default().to_string() } else { path }) +} + +/// Read the arguments after the program name. +pub fn parse(args: &[String]) -> Args { + let mut out = Args::default(); + let mut it = args.iter(); + while let Some(a) = it.next() { + if a == "--control" { + out.control = it.next().and_then(|p| p.parse().ok()); + } else if is_invite(a) { + out.join = Some(a.clone()); + } else if let Some(path) = file_url_path(a) { + out.files.push(path); + } else if !a.starts_with("--") { + out.files.push(a.clone()); + } + } + out +} diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs new file mode 100644 index 0000000..0ed404c --- /dev/null +++ b/apps/figcraft/src/args_tests.rs @@ -0,0 +1,43 @@ +//! Command-line parsing: files, the control port and invite links. + +use crate::args::{Args, file_url_path, is_invite, parse}; + +fn args(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() +} + +#[test] +fn files_and_the_control_port() { + assert_eq!(parse(&args(&[])), Args::default()); + let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"])); + assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None }); + // A missing or bad port is ignored, not taken as a file. + assert_eq!(parse(&args(&["--control"])), Args::default()); + assert_eq!(parse(&args(&["--control", "nope"])).control, None); +} + +#[test] +fn invite_links_join_instead_of_opening() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + let a = parse(&args(&[link])); + assert_eq!(a.join.as_deref(), Some(link)); + assert!(a.files.is_empty(), "a link is never opened as a file"); + // How browsers and shells pass it: any case, a slash after `join`, alongside files. + let a = parse(&args(&["design.figcraft", "FIGCRAFT://join/?host=h:1&file=f&token=t"])); + assert_eq!(a.files, args(&["design.figcraft"])); + assert_eq!(a.join.as_deref(), Some("FIGCRAFT://join/?host=h:1&file=f&token=t")); + assert!(is_invite("figcraft:join") && !is_invite("figcraft.svg") && !is_invite("fig") && !is_invite("ü€figcraft:")); +} + +#[test] +fn file_urls_are_paths() { + // What a Linux desktop passes for `%U`. + let a = parse(&args(&["file:///home/ada/My%20Designs/a+b.figcraft", "FILE://localhost/tmp/x.svg"])); + assert_eq!(a.files, args(&["/home/ada/My Designs/a+b.figcraft", "/tmp/x.svg"]), "spaces decoded, plus signs kept"); + assert_eq!(file_url_path("file:///%E2%82%AC.png").as_deref(), Some("/€.png")); + let win = file_url_path("file:///C:/Designs/a.figcraft"); + assert_eq!(win.as_deref(), Some(if cfg!(windows) { "C:/Designs/a.figcraft" } else { "/C:/Designs/a.figcraft" })); + for bad in ["file://server/share/a.svg", "file:///broken%2", "file:///bad%zz", "file:///%FF", "file:", "fi", "ü€file:///x"] { + assert_eq!(file_url_path(bad), None, "{bad}"); + } +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 5403352..4ac43de 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -1,6 +1,7 @@ //! FigCraft desktop app. //! -//! Usage: `figcraft [--control ] [files…]` +//! Usage: `figcraft [--control ] [files…] [figcraft://join?…]` (an invite link joins that +//! live session; the app registers itself as the opener of such links: `collab.registerLinks`). //! //! `--control ` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}` @@ -8,9 +9,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +mod args; mod control_server; mod services; +#[cfg(test)] +mod args_tests; + use std::sync::{Arc, Mutex}; use figcraft_engine::Session; @@ -82,20 +87,8 @@ fn main() -> eframe::Result { ); return Ok(()); } - let mut port: Option = std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); - let mut files = Vec::new(); - let mut i = 0; - while i < args.len() { - match args.get(i).map(String::as_str) { - Some("--control") => { - port = args.get(i + 1).and_then(|p| p.parse().ok()); - i += 1; - } - Some(f) if !f.starts_with("--") => files.push(f.to_string()), - _ => {} - } - i += 1; - } + let args = args::parse(&args); + let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok())); // System fonts: index them in the background, then wake the UI (an idle window draws no frames) // to lay text out again with them. let ctx_slot: Arc>> = Arc::new(Mutex::new(None)); @@ -114,7 +107,7 @@ fn main() -> eframe::Result { c.request_repaint(); } })))); - for f in &files { + for f in &args.files { if let Err(e) = session.execute("file.open", &json!({"path": f})) { log::error!("{f}: {e}"); } @@ -123,11 +116,18 @@ fn main() -> eframe::Result { if let Some(prefs) = svc.prefs.clone() { session.prefs = prefs; } + // Invite links open this app (Windows registers per user at run time; installed builds by + // default, never a build from source unless asked). + session.live.links = figcraft_collab_net::links::system(); + session.invite_links_startup(); if session.docs.is_empty() { let _ = session.execute("file.new", &json!({})); } let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; + if let Some(link) = &args.join { + figcraft_ui_egui::live::open_invite(&mut fig, link); + } let queue: Arc>> = Arc::new(Mutex::new(Vec::new())); if let Some(p) = port { control_server::start(p, queue.clone(), ctx_slot.clone()); diff --git a/crates/collab-net/Cargo.toml b/crates/collab-net/Cargo.toml index 69368e4..d1b21d6 100644 --- a/crates/collab-net/Cargo.toml +++ b/crates/collab-net/Cargo.toml @@ -13,13 +13,25 @@ figcraft-engine = { workspace = true } figcraft-collab = { workspace = true } figcraft-doc = { workspace = true } log = { workspace = true } +# `std::time::Instant` natively, the browser's clock in the web build. +web-time = "1" -# The transport is native-only for now: the browser client (web-sys WebSocket) is a later step. +# Native transport: blocking tungstenite with a reader and a writer thread per connection. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } getrandom = "0.4" base64 = { workspace = true } +# The browser client: the page's WebSocket API. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Location", "WebSocket", "BinaryType", "MessageEvent", "CloseEvent"] } + +# Opening invite links: the per-user URL handler registration on Windows. +[target.'cfg(windows)'.dependencies] +windows-registry = "0.6" + [dev-dependencies] proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs new file mode 100644 index 0000000..5be4523 --- /dev/null +++ b/crates/collab-net/src/driver.rs @@ -0,0 +1,250 @@ +//! The browser client's connection logic, free of the browser so it runs (and is tested) on any +//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's +//! threads do — hello on open (resuming as the same client after a drop), presence held until the +//! welcome and changes made for an earlier connection dropped (the sync client sends again what +//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as +//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer +//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`]. + +use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; +use figcraft_engine::live::{Event, Status}; +// `std::time::Instant` on native builds; the browser's clock in the web build. +use web_time::Instant; + +use crate::inbox::Inbox; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take}; +use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; + +/// A WebSocket the driver opens, writes and closes; what it receives comes back through the +/// driver's event methods. +pub(crate) trait Socket { + /// Start connecting to `url` (one connection at a time: the driver closes the last first). + fn open(&mut self, url: &str) -> Result<(), String>; + /// Send one binary message; false when the connection can't take it. + fn send(&mut self, bytes: &[u8]) -> bool; + /// Close the connection normally; it reports nothing more. + fn close(&mut self); +} + +/// One connection attempt. +struct Conn { + started: Instant, + opened: bool, + welcomed: bool, + seen: Seen, + last_sent: Instant, + last_heard: Instant, +} + +impl Conn { + fn new(now: Instant) -> Self { + Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now } + } +} + +/// A joined (or joining) session over a [`Socket`]. +pub(crate) struct Driver { + pub(crate) sock: S, + cfg: GuestConfig, + inbox: Inbox, + conn: Option, + was_live: bool, + attempt: u32, + /// What a reconnect resumes with. + resume: Resuming, + /// Your latest presence while not welcomed (sent with the welcome). + pending: Option, + throttle: Throttle, + reconnect_at: Option, + done: bool, +} + +impl Driver { + /// Start joining (progress arrives in `inbox`). + pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self { + let mut d = Self { + sock, + cfg, + inbox, + conn: None, + was_live: false, + attempt: 0, + resume: Resuming::default(), + pending: None, + throttle: Throttle::new(PRESENCE_INTERVAL), + reconnect_at: None, + done: false, + }; + d.inbox.push(Event::Status(Status::Connecting)); + d.connect(now); + d + } + + /// The session is over (left, refused, ended or given up): nothing more will happen. + pub(crate) fn done(&self) -> bool { + self.done + } + + fn connect(&mut self, now: Instant) { + self.reconnect_at = None; + self.conn = Some(Conn::new(now)); + if let Err(e) = self.sock.open(&self.cfg.invite.endpoint()) { + self.end(Outcome::Retry(e), now); + } + } + + /// The connection is open: say hello. + pub(crate) fn opened(&mut self, now: Instant) { + let Some(c) = self.conn.as_mut() else { return }; + c.opened = true; + c.last_heard = now; + let hello = hello(&self.cfg, &self.resume); + if !self.write(&hello, now) { + self.end(Outcome::Retry("the connection dropped".into()), now); + } + } + + /// A binary message from the host. + pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { + let Some(c) = self.conn.as_mut() else { return }; + c.last_heard = now; + let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { + // Single-threaded: the connection counts as welcomed below, before the engine polls. + Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}), + Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))), + }; + match taken { + Taken::Continue => {} + Taken::End(o) => self.end(o, now), + Taken::Welcomed => { + c.welcomed = true; + self.was_live = true; + self.attempt = 0; + if let Some(p) = self.pending.take() { + self.presence(p, now); + } + } + } + } + + /// A text message: not something the host sends. + pub(crate) fn text(&mut self, now: Instant) { + if self.conn.is_none() { + return; + } + self.end(Outcome::Refused("the host sent a text message".into()), now); + } + + /// The connection closed (`code` and `reason` from the close frame; 1006 when it just broke). + pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) { + let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return }; + let outcome = match closed(code, reason) { + Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), + Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()), + o => o, + }; + self.end(outcome, now); + } + + /// Your latest presence (throttled; held until the welcome). + pub(crate) fn presence(&mut self, p: Presence, now: Instant) { + if !self.conn.as_ref().is_some_and(|c| c.welcomed) { + self.pending = Some(p); + return; + } + if let Some(p) = self.throttle.offer(p, now) + && !self.write(&ClientMsg::Presence(p), now) + { + self.end(Outcome::Retry("the connection dropped".into()), now); + } + } + + /// A message from the sync client, in order, on a welcomed connection; dropped otherwise (it + /// was made for an earlier connection: the sync client sends again what the server didn't get + /// once it has caught up with the next welcome). + pub(crate) fn send(&mut self, msg: ClientMsg, now: Instant) { + if !self.conn.as_ref().is_some_and(|c| c.welcomed) { + return; + } + if !self.write(&msg, now) { + self.end(Outcome::Retry("the connection dropped".into()), now); + } + } + + /// Leave the session. + pub(crate) fn leave(&mut self, now: Instant) { + self.end(Outcome::Left, now); + } + + /// When [`tick`](Self::tick) should run next. + pub(crate) fn deadline(&self) -> Option { + if self.done { + return None; + } + let l = &self.cfg.limits; + let conn = self.conn.as_ref().map(|c| { + if c.opened { + [Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }] + } else { + [Some(c.started + self.cfg.connect_timeout), None, None] + } + }); + earliest(conn.into_iter().flatten().chain([self.reconnect_at])) + } + + /// Do what's due at `now`: reconnect, send held presence or a ping, give up on a connection + /// that doesn't answer. + pub(crate) fn tick(&mut self, now: Instant) { + if self.done { + return; + } + if self.reconnect_at.is_some_and(|t| t <= now) { + return self.connect(now); + } + let l = self.cfg.limits; + let Some(c) = self.conn.as_ref() else { return }; + let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent); + if !opened { + if now >= started + self.cfg.connect_timeout { + self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), now); + } + return; + } + if now >= last_heard + l.idle_timeout { + return self.end(Outcome::Retry("the host stopped answering".into()), now); + } + let ok = match self.throttle.take_due(now) { + Some(p) => self.write(&ClientMsg::Presence(p), now), + None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now), + None => true, + }; + if !ok { + self.end(Outcome::Retry("the connection dropped".into()), now); + } + } + + /// Send one message; false when it couldn't go. + fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool { + if let Some(c) = self.conn.as_mut() { + c.last_sent = now; + } + message::encode(msg).is_ok_and(|b| self.sock.send(&b)) + } + + /// The connection ended: close it, then stop or plan the reconnect. + fn end(&mut self, outcome: Outcome, now: Instant) { + if self.conn.take().is_some() { + self.sock.close(); + } + self.throttle = Throttle::new(PRESENCE_INTERVAL); + match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) { + Next::Stop(e) => { + if let Some(e) = e { + self.inbox.push(e); + } + self.done = true; + } + Next::Reconnect(pause) => self.reconnect_at = Some(now + pause), + } + } +} diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs new file mode 100644 index 0000000..6e5a791 --- /dev/null +++ b/crates/collab-net/src/driver_tests.rs @@ -0,0 +1,414 @@ +//! The browser client's connection logic ([`Driver`]) on native: against a scripted socket with a +//! made-up clock (hello, holding presence until the welcome, throttling, pings, a silent host, +//! reconnecting as the same client, refusals, giving up), and against a real host over loopback through +//! a small blocking socket, which checks that what the browser sends is what the host speaks. + +use std::net::TcpStream; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; +use figcraft_collab::{Client, Limits, Outgoing, Server}; +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::live::{Event, Status}; +use tungstenite::protocol::WebSocket; +use tungstenite::{Bytes, Message}; + +use crate::driver::{Driver, Socket}; +use crate::guest::GuestConfig; +use crate::host::{ENDED, Host, HostConfig}; +use crate::inbox::Inbox; +use crate::invite::Invite; +use crate::loopback_tests::fast_limits; + +const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv"; +const FILE: &str = "0123456789abcdef"; + +/// A socket that records what the driver does. +#[derive(Default)] +struct Script { + opened: Vec, + sent: Vec, + closes: usize, + /// `open` fails (the browser refused the URL). + refuse: bool, + /// `send` fails (the connection broke). + broken: bool, +} + +impl Socket for Script { + fn open(&mut self, url: &str) -> Result<(), String> { + if self.refuse { + return Err("SecurityError".into()); + } + self.opened.push(url.to_string()); + Ok(()) + } + fn send(&mut self, bytes: &[u8]) -> bool { + if self.broken { + return false; + } + self.sent.push(message::decode(bytes, usize::MAX).unwrap()); + true + } + fn close(&mut self) { + self.closes += 1; + } +} + +fn invite() -> Invite { + Invite { host: "127.0.0.1:47900".into(), file: FILE.into(), token: TOKEN.into() } +} + +fn start(sock: Script, t0: Instant) -> (Driver