M9: live sessions: host from the app, invite links, live cursors and presence (#104)

* M9: real-time collaboration protocol (design)

docs/collab-protocol.md: WebSocket sessions with a server-ordered,
per-property last-writer-wins document sync, client-scoped ids,
fractional positions for children, ephemeral presence (cursors,
selections, follow), per-user undo, hosting from the app first and a
self-hosted server next, and the implementation plan. ROADMAP gains M9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: collaboration core: ids, change sets, server ordering, convergence

Step 1 of docs/collab-protocol.md, no networking:

- Client-scoped node ids: Document::set_client_id makes alloc_id hand out
  client << 32 | counter; single-user files allocate as before; the id
  counter is no longer part of document equality.
- crates/collab (layer 2): the synced object/property model (every saved
  Node field, plus document-level styles, comments, variables, images,
  dev snapshots, library data), Change batches, an O(changed) diff, strict
  and lenient apply, fractional sibling positions, snapshots.
- Server (ordering, validation, rejects with fixes) and Client (optimistic
  view, replay under remote batches, author-side repair of derived data)
  as pure state machines; binary (version byte + postcard, JSON values) and
  JSON message forms, fuzzed decoders.
- Engine: normalize as the recompute hook, collab.diff.
- Tests: round trips over random real commands, an N-client convergence
  fuzz test over delayed in-order links, junk batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: fmt after merge

* M9: live sessions: host from the app, invite links, live cursors and presence

Step 2 of docs/collab-protocol.md, networking and presence half:

- crates/collab-net (layer 6): WebSocket transport (sync tungstenite, a
  reader and a writer thread per connection, no async runtime), the
  session host the app embeds (runs figcraft_collab::Server as the
  authority, carries its messages), invite links, presence throttling
  (~30 Hz, latest wins), limits (sockets, hello/idle timeouts, message
  size, rate, slow clients), keep-alive, clean close, reconnect with
  backoff; a browser stand-in until the web-sys client (step 4).
- engine: live session state (status, peers, presence, follow) and the
  collab.host/stop/join/leave/peers/follow/cursor commands; the network
  is reached only through the LiveLink the app installs.
- ui-egui: remote cursors (pointer drawn in code, name tag, gliding),
  selection outlines (once documents sync), avatars in the right panel
  with follow, the Share popover (start, invite link + URL with Copy,
  stop), File > Join live session.
- desktop app and MCP headless session install the link; xtask layers
  keeps networking crates out of L0-L5.
- Documents don't sync between apps yet (follow-up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: live sessions: document sync between apps

- The host's session runs figcraft_collab::Server seeded with the shared
  document; every app (the host's too, through the hub without a socket)
  is a collab Client. Guests open the host's file from the welcome as a
  new tab; leaving keeps it as a local copy.
- Engine: after each local action (a gesture once it ends; nothing during
  version or text previews) the session document is diffed into one
  Changes batch; server batches go through Client::receive with
  EngineRecompute and are installed with Session::apply_remote, which
  never touches the undo history. Batches arriving mid-gesture wait.
- Transport: Changes/Reject/Welcome reach the engine as Event::Sync;
  LiveLink::send carries batches in order; hellos are limited to 64 KiB,
  joined clients to the core's message size.
- Tests: engine sync against a real Server (ids in the client's space,
  one batch per gesture, remote edits skip undo, deferral, closing the
  file leaves), three apps over loopback converging (create, move,
  recolour, delete, rename) and a late joiner getting the server's state.
- Docs: collab-protocol, mcp, README, roadmap docs; the UI's
  "cursors only" note is gone, replaced by the per-person undo caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-11 02:40:59 +07:00 committed by GitHub
parent 97159514b4
commit 4d7a9187ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 4782 additions and 64 deletions

169
Cargo.lock generated
View File

@ -541,6 +541,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@ -693,6 +702,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@ -762,6 +782,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -820,6 +846,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -870,12 +905,27 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@ -930,8 +980,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@ -1341,6 +1402,7 @@ dependencies = [
"eframe",
"egui",
"env_logger",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-text",
"figcraft-ui-egui",
@ -1382,6 +1444,21 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-collab-net"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
"figcraft-doc",
"figcraft-engine",
"getrandom 0.4.3",
"log",
"proptest",
"serde_json",
"tungstenite",
]
[[package]]
name = "figcraft-color"
version = "0.2.0"
@ -1461,6 +1538,7 @@ name = "figcraft-mcp"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
"figcraft-doc",
"figcraft-engine",
"figcraft-geom",
@ -1577,6 +1655,7 @@ version = "0.2.0"
dependencies = [
"eframe",
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-ui-egui",
"log",
@ -1801,6 +1880,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
@ -2019,6 +2099,31 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@ -2173,7 +2278,7 @@ dependencies = [
"archery",
"equivalent",
"imbl-sized-chunks",
"rand_core",
"rand_core 0.9.5",
"rand_xoshiro",
"serde_core",
"version_check",
@ -3292,7 +3397,7 @@ dependencies = [
"bit-vec 0.8.0",
"bitflags 2.13.2",
"num-traits",
"rand",
"rand 0.9.5",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
@ -3356,7 +3461,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@ -3366,7 +3482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -3378,13 +3494,19 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -3393,7 +3515,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -3781,6 +3903,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -3788,8 +3921,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@ -4291,6 +4424,22 @@ dependencies = [
"core_maths",
]
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.10.3",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "type-map"
version = "0.5.1"

View File

@ -29,6 +29,7 @@ figcraft-collab = { path = "crates/collab" }
figcraft-engine = { path = "crates/engine" }
figcraft-ui-egui = { path = "crates/ui-egui" }
figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# foundation

View File

@ -139,6 +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).
- **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.
@ -165,7 +166,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has no multiplayer yet (its core is in, networking is next), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app on a local network; no per-person undo, sharing or permissions yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## The Crafting Apps

View File

@ -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, no networking) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
| Collaboration | 12% (core in; live sessions with document sync and cursors, 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 networking on the landed core (M9): 45–90 h.
5. Multiplayer (M9): per-user undo, reconnect with offline edits, the browser client and sharing on top of the live sessions (transport, hosting, invite links, document sync and presence landed): 45–90 h.
## Progress log

View File

@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
figcraft-engine = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
egui = { workspace = true }
log = { workspace = true }

View File

@ -35,7 +35,10 @@ pub fn start() {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let fig = FigApp::new(Session::new(), Box::new(WebServices { reduce_motion }));
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(|| {}))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
Ok(Box::new(WebShell { fig }))
}),
)

View File

@ -13,6 +13,7 @@ workspace = true
[dependencies]
figcraft-engine = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }

View File

@ -107,6 +107,13 @@ fn main() -> eframe::Result {
}
});
let mut session = Session::empty();
// Live sessions: the network threads wake the window when something arrives.
let wake_slot = ctx_slot.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}))));
for f in &files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");

View File

@ -0,0 +1,25 @@
[package]
name = "figcraft-collab-net"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft live sessions over the network: the WebSocket transport, the session host the app embeds, invite links and presence relay (carries figcraft-collab's messages)"
[lints]
workspace = true
[dependencies]
figcraft-engine = { workspace = true }
figcraft-collab = { workspace = true }
figcraft-doc = { workspace = true }
log = { workspace = true }
# The transport is native-only for now: the browser client (web-sys WebSocket) is a later step.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
getrandom = "0.4"
base64 = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }

View File

@ -0,0 +1,383 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
use std::io;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::Client;
use figcraft_collab::message::{self, ClientMsg, Peer, Presence, ServerMsg};
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
use tungstenite::protocol::frame::coding::CloseCode;
use tungstenite::protocol::{CloseFrame, Role as WsRole, WebSocket};
use tungstenite::{Bytes, Message};
use crate::host::ENDED;
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Duplex, NetLimits, config, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// How a guest connects and reconnects.
#[derive(Clone, Debug)]
pub struct GuestConfig {
pub invite: Invite,
/// 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 before giving up.
pub attempts: u32,
}
impl GuestConfig {
pub fn new(invite: Invite, name: String) -> Self {
Self {
invite,
name,
limits: NetLimits::default(),
connect_timeout: Duration::from_secs(5),
backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
attempts: 12,
}
}
}
/// A joined (or joining) session. Dropping it leaves.
pub struct Guest {
cmd: Sender<Cmd>,
thread: Option<JoinHandle<()>>,
}
enum Cmd {
Presence(Presence),
/// For the server, in order (document changes).
Send(ClientMsg),
Leave,
/// The reader of connection `epoch` got its welcome.
Welcomed(u64),
/// Connection `epoch` ended.
Lost(u64, Outcome),
}
/// How a connection ended.
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),
/// You left.
Left,
}
impl Guest {
/// Start joining (returns at once; progress arrives in `inbox`).
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
/// Your latest presence.
pub fn presence(&self, p: Presence) {
let _ = self.cmd.send(Cmd::Presence(p));
}
/// A message from the sync client (sent once the connection is welcomed; dropped when it
/// breaks: the next welcome starts over).
pub fn send(&self, msg: ClientMsg) {
let _ = self.cmd.send(Cmd::Send(msg));
}
/// Leave (returns at once: a connection attempt in progress ends on its own).
pub fn leave(&mut self) {
let _ = self.cmd.send(Cmd::Leave);
}
/// The connection thread has ended.
pub fn finished(&self) -> bool {
self.thread.as_ref().is_none_or(JoinHandle::is_finished)
}
}
impl Drop for Guest {
fn drop(&mut self) {
self.leave();
if let Some(t) = self.thread.take()
&& t.is_finished()
{
let _ = t.join();
}
}
}
/// A connected socket: reader and writer ends.
struct Conn {
reader: WebSocket<Duplex>,
writer: WebSocket<Duplex>,
sock: TcpStream,
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
let addrs: Vec<SocketAddr> = cfg.invite.host.to_socket_addrs().map_err(|e| format!("can't find {}: {e}", cfg.invite.host))?.collect();
let mut last = format!("can't find {}", cfg.invite.host);
let mut stream = None;
for a in addrs {
match TcpStream::connect_timeout(&a, cfg.connect_timeout) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = format!("no answer from {a}: {e}"),
}
}
let stream = stream.ok_or(last)?;
let _ = stream.set_nodelay(true);
stream.set_read_timeout(Some(cfg.limits.hello_timeout)).map_err(|e| e.to_string())?;
stream.set_write_timeout(Some(cfg.limits.write_timeout)).map_err(|e| e.to_string())?;
let (rd, wr, sock) = Duplex::split(stream).map_err(|e| e.to_string())?;
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft session: {e}", cfg.invite.host))?;
let writer = WebSocket::from_raw_socket(wr, WsRole::Client, Some(config(cfg.limits.max_client_message)));
reader.get_ref().set_read_timeout(Some(cfg.limits.idle_timeout)).map_err(|e| e.to_string())?;
Ok(Conn { reader, writer, sock })
}
fn backoff(cfg: &GuestConfig, attempt: u32) -> Duration {
cfg.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(cfg.max_backoff)
}
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
let mut attempt = 0u32;
let mut was_live = false;
let mut pending: Option<Presence> = None;
let mut epoch = 0u64;
inbox.push(Event::Status(Status::Connecting));
loop {
epoch += 1;
let outcome = match connect(cfg) {
Ok(c) => session(c, cfg, inbox, rx, me, epoch, &mut pending, &mut was_live, &mut attempt),
Err(e) => Outcome::Retry(e),
};
match outcome {
Outcome::Left => return,
Outcome::Refused(why) => return inbox.push(Event::Status(Status::Failed { reason: format!("The host refused: {why}") })),
Outcome::Ended(why) => return inbox.push(Event::Ended { reason: why }),
Outcome::Retry(why) if !was_live => {
return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't join the live session: {why}") }));
}
Outcome::Retry(why) => {
attempt += 1;
if attempt > cfg.attempts {
return inbox.push(Event::Status(Status::Failed { reason: format!("Lost the connection to the host ({why})") }));
}
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
inbox.push(Event::Status(Status::Reconnecting { attempt }));
let until = Instant::now() + backoff(cfg, attempt);
loop {
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => return,
Ok(Cmd::Presence(p)) => pending = Some(p),
// From the old connection's client: the next welcome starts over.
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => break,
}
}
}
}
}
}
/// One connection: say hello, then write until it ends.
fn session(
c: Conn,
cfg: &GuestConfig,
inbox: &Inbox,
rx: &Receiver<Cmd>,
me: &Sender<Cmd>,
epoch: u64,
pending: &mut Option<Presence>,
was_live: &mut bool,
attempt: &mut u32,
) -> Outcome {
let Conn { reader, mut writer, sock } = c;
let reader = {
let ctx = ReaderCtx { inbox: inbox.clone(), tx: me.clone(), epoch, cfg: cfg.clone() };
match std::thread::Builder::new().name("live-guest-read".into()).spawn(move || read_loop(reader, &ctx)) {
Ok(t) => t,
Err(e) => {
shutdown(&sock);
return Outcome::Retry(e.to_string());
}
}
};
let mut last_sent = Instant::now();
let hello = Client::hello(&cfg.invite.file, &cfg.invite.token, &cfg.name, "");
let mut welcomed = false;
let mut queued: Vec<ClientMsg> = Vec::new();
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
let outcome = if send(&mut writer, &hello, &mut last_sent) {
loop {
let ping_at = last_sent + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
match rx.recv_timeout(wake.saturating_duration_since(Instant::now())) {
Ok(Cmd::Send(m)) if welcomed => now_send.push(m),
Ok(Cmd::Send(m)) => queued.push(m),
Ok(Cmd::Presence(p)) if welcomed => out = throttle.offer(p, Instant::now()),
Ok(Cmd::Presence(p)) => *pending = Some(p),
Ok(Cmd::Welcomed(e)) if e == epoch => {
welcomed = true;
*was_live = true;
*attempt = 0;
out = pending.take().and_then(|p| throttle.offer(p, Instant::now()));
now_send.append(&mut queued);
}
Ok(Cmd::Lost(e, o)) if e == epoch => break o,
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => {
let _ = writer.close(Some(CloseFrame { code: CloseCode::Normal, reason: "".into() }));
break Outcome::Left;
}
// From an earlier connection.
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
let ok = match out {
Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
None => true,
};
if !ok {
break Outcome::Retry("the connection dropped".into());
}
}
} else {
Outcome::Retry("the connection dropped".into())
};
shutdown(&sock);
let _ = reader.join();
outcome
}
/// Send one message; false when the connection is gone.
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
*last_sent = Instant::now();
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
struct ReaderCtx {
inbox: Inbox,
tx: Sender<Cmd>,
epoch: u64,
cfg: GuestConfig,
}
/// What the reader knows of the session (to tell the UI who's here).
struct Seen {
info: Option<SessionInfo>,
peers: Vec<Peer>,
}
fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
let mut seen = Seen { info: None, peers: Vec::new() };
let outcome = loop {
match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, ctx.cfg.limits.max_server_message) {
Ok(m) => {
if let Some(o) = take(m, ctx, &mut seen) {
break o;
}
}
Err(e) => break Outcome::Refused(format!("the host sent a message this version can't read ({e})")),
},
Ok(Message::Close(f)) => {
break match f {
Some(f) if f.code == CloseCode::Away => {
Outcome::Ended(if f.reason.is_empty() { ENDED.to_string() } else { f.reason.to_string() })
}
Some(f) if f.code == CloseCode::Policy => Outcome::Refused(f.reason.to_string()),
_ => Outcome::Retry("the host closed the connection".into()),
};
}
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
Ok(_) => {}
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
Err(e) => break Outcome::Retry(e.to_string()),
}
};
let _ = ctx.tx.send(Cmd::Lost(ctx.epoch, outcome));
}
/// Take in one message from the host; `Some` when the connection should end.
fn take(m: ServerMsg, ctx: &ReaderCtx, seen: &mut Seen) -> Option<Outcome> {
match m {
ServerMsg::Welcome { client_id, ref peers, .. } => {
// The sync client opens the document (and fills in its name and pages).
let peers = peers.clone();
let inv = &ctx.cfg.invite;
let network = inv.host.parse::<SocketAddr>().map_or(true, |a| !a.ip().is_loopback());
let info = SessionInfo {
role: Role::Guest,
me: client_id,
name: ctx.cfg.name.clone(),
color: String::new(),
file_name: String::new(),
pages: Vec::new(),
link: inv.link(),
url: inv.url(),
address: inv.host.clone(),
network,
};
seen.peers = peers.clone();
seen.info = Some(info.clone());
ctx.inbox.push(Event::Welcome { info, peers });
ctx.inbox.push(Event::Sync(m));
let _ = ctx.tx.send(Cmd::Welcomed(ctx.epoch));
}
ServerMsg::PeerJoined(p) => match seen.info.as_mut() {
// The host tells you how you are shown.
Some(info) if p.client_id == info.me => {
info.name = p.name;
info.color = p.color;
ctx.inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() });
}
_ => {
seen.peers.retain(|q| q.client_id != p.client_id);
seen.peers.push(p.clone());
ctx.inbox.push(Event::PeerJoined(p));
}
},
ServerMsg::PeerLeft { client_id } => {
seen.peers.retain(|q| q.client_id != client_id);
ctx.inbox.push(Event::PeerLeft(client_id));
}
ServerMsg::Presence { client_id, presence } => {
if seen.info.as_ref().is_some_and(|i| i.me != client_id) {
ctx.inbox.presence(client_id, presence);
}
}
ServerMsg::Refused { reason } => return Some(Outcome::Refused(reason)),
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => ctx.inbox.push(Event::Sync(m)),
ServerMsg::Pong => {}
}
None
}

View File

@ -0,0 +1,617 @@
//! The session host the app embeds: a WebSocket server for one file that runs the collaboration
//! core's [`Server`] (the ordering authority) and carries its messages.
//!
//! Threads: one accepts connections; each connection has a reader and a writer (see [`crate::net`]);
//! one hub owns the [`Server`] and everything shared, fed by a channel. The person hosting takes
//! part through the hub directly (no socket): their presence goes in with [`Host::presence`],
//! what others do comes out through their [`Inbox`].
//!
//! On top of the core the hub checks the invite's file id, hands out colours and default names,
//! throttles presence to about 30 updates a second per person (latest wins), tells a newcomer
//! its own name and colour (a `PeerJoined` of itself, after its welcome) and where everyone
//! already is, and disconnects clients that send too much, too fast, or can't keep up.
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
use figcraft_collab::{Outgoing, Server};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
use tungstenite::protocol::frame::coding::CloseCode;
use tungstenite::protocol::{CloseFrame, Role, WebSocket};
use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::net::{Bucket, Duplex, NetLimits, config, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
/// What the host tells everyone when it stops (the WebSocket close reason).
pub const ENDED: &str = "The host ended the session";
/// What a hosted session needs.
pub struct HostConfig {
/// The address to listen on (port 0: any free port).
pub addr: SocketAddr,
/// When `addr`'s port is taken, take any free one instead.
pub any_port_if_taken: bool,
/// The file's session id and secret (both in the invite).
pub file: String,
pub token: String,
/// The shared document (the session's authoritative copy starts from it).
pub doc: Document,
/// The host's own name (empty: "Host").
pub name: String,
pub limits: NetLimits,
pub core: figcraft_collab::Limits,
}
/// A running session host. Dropping it stops it.
pub struct Host {
addr: SocketAddr,
me: Peer,
hub: Sender<HubIn>,
stop: Arc<AtomicBool>,
open: Arc<AtomicUsize>,
hub_thread: Option<JoinHandle<()>>,
accept_thread: Option<JoinHandle<()>>,
}
/// A message for one connection's writer.
enum Out {
Bytes(Bytes),
Close(CloseCode, String),
}
enum HubIn {
Join {
conn: u64,
hello: Hello,
out: SyncSender<Out>,
sock: TcpStream,
},
Msg {
conn: u64,
msg: ClientMsg,
},
Gone {
conn: u64,
},
Local(Presence),
LocalMsg(ClientMsg),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
Stop,
}
fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
std::thread::Builder::new().name(name.to_string()).spawn(f)
}
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
}
impl Host {
/// Listen and start the hub; the host joins its own session as the first person.
pub fn start(cfg: HostConfig, inbox: Inbox) -> io::Result<Host> {
let listener = match TcpListener::bind(cfg.addr) {
Ok(l) => l,
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
Err(e) => return Err(e),
};
let addr = listener.local_addr()?;
let mut server = Server::new(cfg.doc, cfg.token.clone(), cfg.core);
let name = match clean_name(&cfg.name) {
n if n.is_empty() => "Host".to_string(),
n => n,
};
let hello = Hello {
protocol: PROTOCOL,
file: cfg.file.clone(),
token: cfg.token,
name,
color: PEOPLE[0].to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
};
let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?;
// The host's window syncs through its own client too: its welcome carries the document.
for o in outs {
if let Outgoing::To(id, w @ ServerMsg::Welcome { .. }) = o
&& id == me
{
inbox.push(Event::Sync(w));
}
}
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
let (hub_tx, hub_rx) = channel();
let stop = Arc::new(AtomicBool::new(false));
let open = Arc::new(AtomicUsize::new(0));
let hub = Hub {
server,
file: cfg.file,
local: me.client_id,
inbox,
conns: HashMap::new(),
ids: BTreeMap::new(),
throttles: BTreeMap::new(),
latest: BTreeMap::new(),
guests: 0,
};
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
let accept_thread = {
let (hub_tx, stop, open, limits) = (hub_tx.clone(), stop.clone(), open.clone(), cfg.limits);
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits))
};
let accept_thread = match accept_thread {
Ok(t) => t,
Err(e) => {
let _ = hub_tx.send(HubIn::Stop);
let _ = hub_thread.join();
return Err(e);
}
};
log::info!("live session: hosting on {addr}");
Ok(Host { addr, me, hub: hub_tx, stop, open, hub_thread: Some(hub_thread), accept_thread: Some(accept_thread) })
}
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// The host as the others see them.
pub fn me(&self) -> &Peer {
&self.me
}
/// Open connections (joined or not).
pub fn connections(&self) -> usize {
self.open.load(Ordering::Relaxed)
}
/// The host's own presence.
pub fn presence(&self, p: Presence) {
let _ = self.hub.send(HubIn::Local(p));
}
/// A message from the host's own sync client (its edits).
pub fn send(&self, msg: ClientMsg) {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
let _ = self.hub.send(HubIn::Cut);
}
/// End the session: everyone is told and disconnected, the port is released.
pub fn stop(&mut self) {
if self.stop.swap(true, Ordering::SeqCst) {
return;
}
let _ = self.hub.send(HubIn::Stop);
if let Some(t) = self.hub_thread.take() {
let _ = t.join();
}
// The accept loop checks the flag after each connection: wake it with one.
let wake = if self.addr.ip().is_unspecified() { SocketAddr::from(([127, 0, 0, 1], self.addr.port())) } else { self.addr };
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
if let Some(t) = self.accept_thread.take() {
if woke {
let _ = t.join();
} else {
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
}
}
// Readers end once their writers have closed the sockets.
let deadline = Instant::now() + Duration::from_secs(2);
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
log::info!("live session: stopped hosting");
}
}
impl Drop for Host {
fn drop(&mut self) {
self.stop();
}
}
/// Counts an open connection until dropped.
struct Open(Arc<AtomicUsize>);
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits) {
let mut next = 0u64;
for stream in listener.incoming() {
if stop.load(Ordering::SeqCst) {
break;
}
let stream = match stream {
Ok(s) => s,
Err(e) => {
// Out of sockets or similar: don't spin.
log::warn!("live session: accept failed: {e}");
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
if open.load(Ordering::Relaxed) >= limits.max_connections {
log::info!("live session: too many connections; refusing one");
continue;
}
open.fetch_add(1, Ordering::Relaxed);
let guard = Open(open.clone());
next += 1;
let (conn, hub) = (next, hub.clone());
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) {
log::warn!("live session: can't start a connection thread: {e}");
}
}
}
/// A close reason that fits a WebSocket close frame (123 bytes).
fn reason(s: &str) -> String {
let mut end = s.len().min(123);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default().to_string()
}
fn encode(msg: &ServerMsg) -> Option<Bytes> {
match message::encode(msg) {
Ok(b) => Some(Bytes::from(b)),
Err(e) => {
log::warn!("live session: can't encode a message: {e}");
None
}
}
}
/// Queue `Refused {reason}` and a close for one connection.
fn refuse(out: &SyncSender<Out>, why: &str) {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.try_send(Out::Bytes(b));
}
let _ = out.try_send(Out::Close(CloseCode::Policy, reason(why)));
}
/// Read the next binary message (skipping pings), or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<ClientMsg>, &'static str> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => message::decode::<ClientMsg>(&b, max).map(Some).map_err(|_| "that message couldn't be read"),
Ok(Message::Text(_)) => Err("only binary messages are spoken here"),
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err("message too large"),
Err(_) => Ok(None),
};
}
}
/// One connection's reader (the writer runs on its own thread).
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
let _ = stream.set_nodelay(true);
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok((rd, wr, sock)) = Duplex::split(stream) else { return };
let cfg = config(limits.max_client_message);
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
Ok(ws) => ws,
Err(e) => {
log::debug!("live session: handshake failed: {e}");
shutdown(&sock);
return;
}
};
let (out, rx) = sync_channel::<Out>(limits.queue.max(4));
let writer =
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
Ok(Ok(t)) => t,
_ => {
shutdown(&sock);
return;
}
};
let end = |why: Option<&str>| {
if let Some(why) = why {
refuse(&out, why);
} else {
let _ = out.try_send(Out::Close(CloseCode::Normal, String::new()));
}
};
match next_message(&mut ws, limits.max_hello) {
Ok(Some(ClientMsg::Hello(hello))) => {
ws.set_config(|c| *c = cfg);
let joined = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Join { conn, hello, out: out.clone(), sock: s }).is_ok());
if joined && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
read_loop(&mut ws, conn, hub, limits, &end);
let _ = hub.send(HubIn::Gone { conn });
}
}
Ok(Some(_)) => end(Some("say hello first")),
Ok(None) => {}
Err(why) => end(Some(why)),
}
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
let _ = writer.join();
shutdown(&sock);
}
fn read_loop(ws: &mut WebSocket<Duplex>, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, end: &dyn Fn(Option<&str>)) {
let mut bucket = Bucket::new(limits.rate, limits.burst);
loop {
match next_message(ws, limits.max_client_message) {
Ok(Some(msg)) => {
if !bucket.take(Instant::now()) {
log::info!("live session: connection {conn} sends too much; disconnecting it");
return end(Some("too many messages"));
}
if hub.send(HubIn::Msg { conn, msg }).is_err() {
return;
}
}
Ok(None) => return end(None),
Err(why) => return end(Some(why)),
}
}
}
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
let mut close = Some((CloseCode::Normal, String::new()));
while let Ok(o) = rx.recv() {
match o {
Out::Bytes(b) => {
if ws.send(Message::Binary(b)).is_err() {
close = None;
break;
}
}
Out::Close(code, why) => {
close = Some((code, why));
break;
}
}
}
if let Some((code, why)) = close {
// Close politely: the close frame, then no more writing; the reader drains what the peer
// still sends until it hangs up (closing on unread data would reset the connection and
// could lose the close frame), or until the linger runs out.
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
let _ = sock.shutdown(std::net::Shutdown::Write);
let until = Instant::now() + LINGER;
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
if Instant::now() >= until {
break;
}
}
}
shutdown(sock);
}
/// Read and drop what the peer still sends after the close, until it hangs up (at most
/// [`LINGER`]).
fn linger(ws: &mut WebSocket<Duplex>) {
let until = Instant::now() + LINGER;
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
match ws.read() {
Ok(Message::Close(_)) => break,
Ok(_) => {}
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
Err(_) => break,
}
}
}
/// A joined or joining connection.
struct Conn {
out: SyncSender<Out>,
sock: TcpStream,
id: u32,
}
/// Owns the collaboration server and routes its messages.
struct Hub {
server: Server,
file: String,
/// The host's own client id.
local: u32,
inbox: Inbox,
conns: HashMap<u64, Conn>,
/// Client id → connection.
ids: BTreeMap<u32, u64>,
throttles: BTreeMap<u32, Throttle<Presence>>,
/// Everyone's latest presence (for newcomers).
latest: BTreeMap<u32, Presence>,
/// Default names handed out ("Guest 1", "Guest 2"…).
guests: u32,
}
impl Hub {
fn run(mut self, rx: &Receiver<HubIn>) {
loop {
let due = earliest(self.throttles.values().map(Throttle::due));
let msg = match due {
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
Ok(m) => Some(m),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => Some(HubIn::Stop),
},
None => Some(rx.recv().unwrap_or(HubIn::Stop)),
};
match msg {
Some(HubIn::Stop) => break,
Some(m) => self.handle(m),
None => {}
}
self.flush_due(Instant::now());
}
for c in self.conns.values() {
if c.out.try_send(Out::Close(CloseCode::Away, ENDED.into())).is_err() {
shutdown(&c.sock);
}
}
}
fn handle(&mut self, m: HubIn) {
match m {
HubIn::Join { conn, hello, out, sock } => self.join(conn, hello, out, sock),
HubIn::Msg { conn, msg } => {
let Some(id) = self.conns.get(&conn).map(|c| c.id) else { return };
match msg {
ClientMsg::Presence(p) => self.presence(id, p),
other => {
let outs = self.server.receive(id, other);
self.deliver(outs);
}
}
}
HubIn::Gone { conn } => self.gone(conn),
HubIn::Local(p) => self.presence(self.local, p),
HubIn::LocalMsg(ClientMsg::Presence(p)) => self.presence(self.local, p),
HubIn::LocalMsg(msg) => {
let outs = self.server.receive(self.local, msg);
self.deliver(outs);
}
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
shutdown(&c.sock);
}
}
HubIn::Stop => {}
}
}
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, sock: TcpStream) {
if hello.file != self.file {
return refuse(&out, "this invite is for another file or an earlier session");
}
// Colours: the first one nobody has.
let taken: Vec<&str> = self.server.peers().map(|p| p.color.as_str()).collect();
let color = PEOPLE.iter().find(|c| !taken.contains(c)).copied().unwrap_or(PEOPLE[taken.len() % PEOPLE.len()]);
hello.color = color.to_string();
hello.name = match clean_name(&hello.name) {
n if n.is_empty() => {
self.guests += 1;
format!("Guest {}", self.guests)
}
n => n,
};
match self.server.join(hello) {
Ok((id, outs)) => {
self.conns.insert(conn, Conn { out, sock, id });
self.ids.insert(id, conn);
self.deliver(outs);
// The newcomer learns how it is shown, then where everyone is.
let mut more = Vec::new();
if let Some(me) = self.server.peers().find(|p| p.client_id == id) {
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
}
more.extend(self.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() })));
self.deliver(more);
}
Err(ServerMsg::Refused { reason }) => refuse(&out, &reason),
Err(_) => refuse(&out, "refused"),
}
}
fn gone(&mut self, conn: u64) {
let Some(c) = self.conns.remove(&conn) else { return };
self.ids.remove(&c.id);
self.throttles.remove(&c.id);
self.latest.remove(&c.id);
let outs = self.server.leave(c.id);
self.deliver(outs);
}
fn presence(&mut self, id: u32, p: Presence) {
let p = sanitize(p);
self.latest.insert(id, p.clone());
let now = Instant::now();
if let Some(p) = self.throttles.entry(id).or_insert_with(|| Throttle::new(PRESENCE_INTERVAL)).offer(p, now) {
let outs = self.server.receive(id, ClientMsg::Presence(p));
self.deliver(outs);
}
}
fn flush_due(&mut self, now: Instant) {
let due: Vec<(u32, Presence)> = self.throttles.iter_mut().filter_map(|(id, t)| t.take_due(now).map(|p| (*id, p))).collect();
for (id, p) in due {
let outs = self.server.receive(id, ClientMsg::Presence(p));
self.deliver(outs);
}
}
/// Send what the server said; connections that can't keep up are dropped.
fn deliver(&mut self, outs: Vec<Outgoing>) {
let mut stuck = Vec::new();
for o in outs {
let (to, msg): (Vec<u32>, ServerMsg) = match o {
Outgoing::To(id, m) => (vec![id], m),
Outgoing::All(m) => (self.server.peers().map(|p| p.client_id).collect(), m),
Outgoing::Others(x, m) => (self.server.peers().map(|p| p.client_id).filter(|id| *id != x).collect(), m),
};
let mut bytes = None;
for id in to {
if id == self.local {
self.to_local(&msg);
continue;
}
let Some(c) = self.ids.get(&id).and_then(|conn| self.conns.get(conn)) else { continue };
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
match c.out.try_send(Out::Bytes(b)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
log::info!("live session: client {id} can't keep up; disconnecting it");
stuck.push(id);
}
Err(TrySendError::Disconnected(_)) => stuck.push(id),
}
}
}
for id in stuck {
if let Some(&conn) = self.ids.get(&id) {
if let Some(c) = self.conns.get(&conn) {
shutdown(&c.sock);
}
self.gone(conn);
}
}
}
/// What the host's own window should know.
fn to_local(&self, msg: &ServerMsg) {
match msg {
ServerMsg::PeerJoined(p) if p.client_id != self.local => self.inbox.push(Event::PeerJoined(p.clone())),
ServerMsg::PeerLeft { client_id } => self.inbox.push(Event::PeerLeft(*client_id)),
ServerMsg::Presence { client_id, presence } => self.inbox.presence(*client_id, presence.clone()),
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => self.inbox.push(Event::Sync(msg.clone())),
_ => {}
}
}
}

View File

@ -0,0 +1,69 @@
//! Where the network threads leave what happened for the UI thread, which takes it with
//! [`Inbox::drain`] when the waker gets it to draw a frame. Presence is latest-wins per peer, so
//! an app that polls rarely (a headless agent) never piles up cursor moves.
use std::collections::{BTreeMap, VecDeque};
use std::sync::{Arc, Mutex, PoisonError};
use figcraft_engine::live::{Event, Presence};
/// Wakes the UI (the desktop app passes egui's `request_repaint`).
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// Most discrete events kept between two drains (joins, leaves, status); the oldest go first.
const MAX_EVENTS: usize = 10_000;
#[derive(Default)]
struct State {
events: VecDeque<Event>,
presence: BTreeMap<u32, Presence>,
}
/// One session's mailbox (a new session gets a new one, so a stopped session's threads can't
/// reach the next).
#[derive(Clone)]
pub struct Inbox {
state: Arc<Mutex<State>>,
wake: Waker,
}
impl Inbox {
pub fn new(wake: Waker) -> Self {
Self { state: Arc::default(), wake }
}
fn with<R>(&self, f: impl FnOnce(&mut State) -> R) -> R {
let r = f(&mut self.state.lock().unwrap_or_else(PoisonError::into_inner));
(self.wake)();
r
}
pub fn push(&self, e: Event) {
self.with(|s| {
if let Event::PeerLeft(id) = &e {
s.presence.remove(id);
}
if s.events.len() >= MAX_EVENTS {
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
s.events.pop_front();
}
s.events.push_back(e);
});
}
/// Peer `id`'s latest presence (replaces one not taken yet).
pub fn presence(&self, id: u32, p: Presence) {
self.with(|s| {
s.presence.insert(id, p);
});
}
/// Everything since the last drain: the events in order, then the latest presence of each
/// peer.
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut out: Vec<Event> = s.events.drain(..).collect();
out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p)));
out
}
}

View File

@ -0,0 +1,111 @@
//! Invite links: `figcraft://join?host=<ip:port>&file=<id>&token=<secret>`, or the same session as a
//! plain WebSocket URL `ws://<ip:port>/?file=<id>&token=<secret>`.
//!
//! The token never travels in the WebSocket request (a proxy's logs would keep it): the client
//! connects to `ws://<host>/` and sends the token in its `Hello`. Errors never echo the link.
/// What an invite link names.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Invite {
/// `host:port` (an IP address, `[v6]:port`, or a host name).
pub host: String,
/// The shared file's session id.
pub file: String,
/// The session's secret.
pub token: String,
}
/// Longest link accepted (anything longer isn't one of ours).
const MAX_LINK: usize = 2048;
impl Invite {
/// The `figcraft://join?…` link.
pub fn link(&self) -> String {
format!("figcraft://join?host={}&file={}&token={}", self.host, self.file, self.token)
}
/// The plain WebSocket URL of the same session.
pub fn url(&self) -> String {
format!("ws://{}/?file={}&token={}", self.host, self.file, self.token)
}
/// What the client connects to (no secrets in it).
pub fn endpoint(&self) -> String {
format!("ws://{}/", self.host)
}
/// Read an invite link (either form; surrounding spaces, quotes or angle brackets are fine).
pub fn parse(link: &str) -> Result<Invite, String> {
const NOT: &str = "that's not a live session invite: expected figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…";
let link = link.trim().trim_matches(|c| matches!(c, '<' | '>' | '"' | '\''));
if link.len() > MAX_LINK {
return Err(NOT.into());
}
let (scheme, rest) = link.split_once("://").ok_or(NOT)?;
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
let mut host = None;
let mut file = None;
let mut token = None;
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = percent_decode(v).ok_or(NOT)?;
match k {
"host" => host = Some(v),
"file" => file = Some(v),
"token" => token = Some(v),
_ => {}
}
}
if scheme.eq_ignore_ascii_case("figcraft") {
if !path.trim_end_matches('/').eq_ignore_ascii_case("join") {
return Err(NOT.into());
}
} else if scheme.eq_ignore_ascii_case("ws") {
// ws://host:port/… : the host is the authority.
host = Some(path.split('/').next().unwrap_or("").to_string());
} else if scheme.eq_ignore_ascii_case("wss") {
return Err("secure (wss://) live sessions need a server behind TLS, which comes with the standalone server".into());
} else {
return Err(NOT.into());
}
let host = host.filter(|h| valid_host(h)).ok_or("the invite link's host isn't an address with a port (like 192.168.1.20:47900)")?;
let file = file.filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
let token = token.filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok(Invite { host, file, token })
}
}
/// Ids and tokens are URL-safe base64 / hex characters.
fn valid_id(s: &str, min: usize, max: usize) -> bool {
(min..=max).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// `ip:port`, `[v6]:port` or `name:port` with a non-zero port.
fn valid_host(s: &str) -> bool {
if s.parse::<std::net::SocketAddr>().is_ok_and(|a| a.port() != 0) {
return true;
}
let Some((name, port)) = s.rsplit_once(':') else { return false };
port.parse::<u16>().is_ok_and(|p| p != 0)
&& (1..=253).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
&& !name.starts_with(['-', '.'])
}
/// `%XX` escapes decoded (UTF-8); `None` for broken escapes.
fn percent_decode(s: &str) -> Option<String> {
let bytes = s.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 = bytes.get(i + 1..i + 3)?;
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
i += 3;
} else {
out.push(if b == b'+' { b' ' } else { b });
i += 1;
}
}
String::from_utf8(out).ok()
}

View File

@ -0,0 +1,74 @@
//! Invite links: both forms read back, bad links are refused without echoing them, and the
//! parser survives anything.
use proptest::prelude::*;
use crate::invite::Invite;
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
fn invite() -> Invite {
Invite { host: "192.168.1.20:47900".into(), file: "0123456789abcdef".into(), token: TOKEN.into() }
}
#[test]
fn both_forms_read_back() {
let i = invite();
assert_eq!(i.link(), format!("figcraft://join?host=192.168.1.20:47900&file=0123456789abcdef&token={TOKEN}"));
assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
assert_eq!(Invite::parse(&i.url()), Ok(i.clone()));
// Pasted with spaces, quotes or angle brackets, or with escapes.
assert_eq!(Invite::parse(&format!(" <{}>\n", i.link())), Ok(i.clone()));
assert_eq!(Invite::parse(&format!("\"{}\"", i.url())), Ok(i.clone()));
assert_eq!(Invite::parse(&format!("figcraft://join?host=192.168.1.20%3A47900&file=0123456789abcdef&token={TOKEN}")), Ok(i.clone()));
// The secret never travels in the WebSocket request.
assert_eq!(i.endpoint(), "ws://192.168.1.20:47900/");
}
#[test]
fn hosts_can_be_v6_or_names() {
let v6 = Invite { host: "[::1]:47900".into(), ..invite() };
assert_eq!(Invite::parse(&v6.link()), Ok(v6.clone()));
assert_eq!(Invite::parse(&v6.url()), Ok(v6));
let name = Invite { host: "studio.local:47900".into(), ..invite() };
assert_eq!(Invite::parse(&name.link()), Ok(name));
}
#[test]
fn bad_links_are_refused_without_echoing_them() {
let secret = "SuperSecretToken123456";
for bad in [
String::new(),
"hello".into(),
format!("https://example.com/?token={secret}"),
format!("figcraft://node/12?token={secret}"),
format!("figcraft://join?file=abc&token={secret}"),
format!("figcraft://join?host=10.0.0.1&file=abc&token={secret}"),
format!("figcraft://join?host=10.0.0.1:0&file=abc&token={secret}"),
format!("figcraft://join?host=10.0.0.1:47900&token={secret}"),
"figcraft://join?host=10.0.0.1:47900&file=abc&token=short".into(),
format!("figcraft://join?host=10.0.0.1:47900&file=a/b&token={secret}"),
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={secret}%zz"),
format!("wss://10.0.0.1:47900/?file=abc&token={secret}"),
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={}", "x".repeat(3000)),
] {
let e = Invite::parse(&bad).expect_err(&bad);
assert!(!e.contains(secret), "{e}");
}
}
proptest! {
#[test]
fn the_parser_survives_anything(s in ".{0,300}") {
let _ = Invite::parse(&s);
let _ = Invite::parse(&format!("figcraft://join?{s}"));
let _ = Invite::parse(&format!("ws://{s}"));
}
#[test]
fn valid_invites_round_trip(a in 1u8..255, port in 1u16.., file in "[a-f0-9]{1,64}", token in "[A-Za-z0-9_-]{16,128}") {
let i = Invite { host: format!("10.0.0.{a}:{port}"), file, token };
prop_assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
}
}

View File

@ -0,0 +1,46 @@
//! Live sessions over the network for FigCraft (milestone M9, step 2 of
//! `docs/collab-protocol.md`): the WebSocket transport, the session host the desktop app embeds,
//! invite links, and presence relay. It carries the collaboration core's messages
//! ([`figcraft_collab::message`]) and runs its [`Server`](figcraft_collab::Server) as the host's
//! authority.
//!
//! The app installs a [`NetLink`] into the engine session (`session.live.install(…)`); the
//! engine's `collab.*` commands drive it and the UI reads the engine's live state. Network
//! threads wake the UI through a [`Waker`] only when something arrives; nothing polls.
//!
//! The browser build has a stand-in [`NetLink`] whose host and join say it's not available yet
//! (a `web-sys` WebSocket client is step 4).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod inbox;
pub mod invite;
#[cfg(not(target_arch = "wasm32"))]
pub mod guest;
#[cfg(not(target_arch = "wasm32"))]
pub mod host;
#[cfg(not(target_arch = "wasm32"))]
mod link;
#[cfg(not(target_arch = "wasm32"))]
pub mod net;
#[cfg(not(target_arch = "wasm32"))]
pub mod throttle;
#[cfg(not(target_arch = "wasm32"))]
pub mod token;
#[cfg(target_arch = "wasm32")]
mod web;
pub use inbox::{Inbox, Waker};
pub use invite::Invite;
#[cfg(not(target_arch = "wasm32"))]
pub use link::{DEFAULT_PORT, NetLink, lan_ip};
#[cfg(target_arch = "wasm32")]
pub use web::NetLink;
#[cfg(test)]
mod invite_tests;
#[cfg(test)]
mod loopback_tests;
#[cfg(test)]
mod session_tests;

View File

@ -0,0 +1,165 @@
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use figcraft_collab::message::ClientMsg;
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, Role, SessionInfo};
use crate::guest::{Guest, GuestConfig};
use crate::host::{Host, HostConfig};
use crate::inbox::{Inbox, Waker};
use crate::invite::Invite;
use crate::net::NetLimits;
use crate::token;
/// The port a hosted session tries first.
pub const DEFAULT_PORT: u16 = 47900;
enum Active {
Host(Host),
Guest(Guest),
}
/// Hosts or joins one session at a time.
pub struct NetLink {
wake: Waker,
limits: NetLimits,
active: Option<Active>,
inbox: Option<Inbox>,
/// Tweaks for joining (tests shorten the reconnect pauses).
guest: fn(GuestConfig) -> GuestConfig,
}
impl NetLink {
/// `wake` is called (from network threads) whenever something arrives for the UI.
pub fn new(wake: Waker) -> Self {
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c }
}
/// With other transport limits.
pub fn with_limits(mut self, limits: NetLimits) -> Self {
self.limits = limits;
self
}
/// With joins configured by `f` (reconnect pauses, attempts).
pub fn with_guest_config(mut self, f: fn(GuestConfig) -> GuestConfig) -> Self {
self.guest = f;
self
}
/// The running host, if hosting.
pub fn host_handle(&self) -> Option<&Host> {
match &self.active {
Some(Active::Host(h)) => Some(h),
_ => None,
}
}
}
/// This machine's address on the local network: the interface the default route leaves by (no
/// packet is sent).
pub fn lan_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?;
// Documentation and private ranges: only the route matters.
for probe in ["192.0.2.1:9", "10.255.255.255:9"] {
if sock.connect(probe).is_ok()
&& let Ok(a) = sock.local_addr()
&& !a.ip().is_loopback()
&& !a.ip().is_unspecified()
{
return Some(a.ip());
}
}
None
}
impl LiveLink for NetLink {
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
self.leave();
let ip = match o.bind {
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
Bind::Addr(ip) => ip,
};
let (file, token) = (token::file_id()?, token::secret()?);
let inbox = Inbox::new(self.wake.clone());
let doc = (*o.doc).clone();
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
let file_name = doc.name.clone();
let cfg = HostConfig {
addr: SocketAddr::new(ip, o.port.unwrap_or(DEFAULT_PORT)),
any_port_if_taken: o.port.is_none(),
file: file.clone(),
token: token.clone(),
doc,
name: o.name,
limits: self.limits,
core: figcraft_collab::Limits::default(),
};
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
let invite = Invite { host: host.addr().to_string(), file, token };
let info = SessionInfo {
role: Role::Host,
me: host.me().client_id,
name: host.me().name.clone(),
color: host.me().color.clone(),
file_name,
pages,
link: invite.link(),
url: invite.url(),
address: host.addr().to_string(),
network: !ip.is_loopback(),
};
self.inbox = Some(inbox);
self.active = Some(Active::Host(host));
Ok(info)
}
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
self.leave();
let inbox = Inbox::new(self.wake.clone());
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
cfg.limits = self.limits;
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
Ok(())
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),
Some(Active::Guest(mut g)) => g.leave(),
None => {}
}
self.inbox = None;
}
fn presence(&mut self, p: &Presence) {
match &self.active {
Some(Active::Host(h)) => h.presence(p.clone()),
Some(Active::Guest(g)) => g.presence(p.clone()),
None => {}
}
}
fn send(&mut self, msg: ClientMsg) {
match &self.active {
Some(Active::Host(h)) => h.send(msg),
Some(Active::Guest(g)) => g.send(msg),
None => {}
}
}
fn poll(&mut self) -> Vec<Event> {
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
}
}
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
}
}

View File

@ -0,0 +1,553 @@
//! The transport and the host end to end over loopback (127.0.0.1, ephemeral ports): joining and
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
//! reconnecting. Every test stops its host and closes its sockets.
use std::io::Read;
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
use crate::guest::{Guest, GuestConfig};
use crate::host::{ENDED, Host, HostConfig};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Bucket, NetLimits};
use crate::throttle::Throttle;
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
const FILE: &str = "0123456789abcdef";
const WAIT: Duration = Duration::from_secs(10);
/// One participant's view: the events its inbox got so far.
pub(crate) struct Probe {
pub inbox: Inbox,
pub events: Vec<Event>,
}
impl Probe {
pub fn new() -> Self {
Self { inbox: Inbox::new(Arc::new(|| {})), events: Vec::new() }
}
/// Wait until `f` holds for the events so far.
pub fn wait(&mut self, what: &str, f: impl Fn(&[Event]) -> bool) {
let start = Instant::now();
loop {
self.events.extend(self.inbox.drain());
if f(&self.events) {
return;
}
assert!(start.elapsed() < WAIT, "timed out waiting for {what}: {:#?}", self.events);
std::thread::sleep(Duration::from_millis(5));
}
}
/// The client id this guest was welcomed with (latest welcome).
pub fn me(&self) -> u32 {
self.events.iter().rev().find_map(|e| if let Event::Welcome { info, .. } = e { Some(info.me) } else { None }).unwrap()
}
pub fn presence_of(&self, id: u32) -> Option<&Presence> {
self.events.iter().rev().find_map(|e| match e {
Event::Presence(from, p) if *from == id => Some(p),
_ => None,
})
}
fn joined(&self) -> Vec<u32> {
self.events.iter().filter_map(|e| if let Event::PeerJoined(p) = e { Some(p.client_id) } else { None }).collect()
}
fn left(&self) -> Vec<u32> {
self.events.iter().filter_map(|e| if let Event::PeerLeft(id) = e { Some(*id) } else { None }).collect()
}
}
fn welcomed(e: &[Event]) -> bool {
e.iter().any(|e| matches!(e, Event::Welcome { .. }))
}
pub(crate) fn fast_limits() -> NetLimits {
NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }
}
fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
let probe = Probe::new();
let mut doc = Document::new("Shared file");
doc.repair();
let cfg = HostConfig {
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
doc,
name: "Hana".into(),
limits,
core,
};
let host = Host::start(cfg, probe.inbox.clone()).unwrap();
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
(host, probe, invite)
}
fn start() -> (Host, Probe, Invite) {
start_with(fast_limits(), Limits::default())
}
pub(crate) fn quick(mut c: GuestConfig) -> GuestConfig {
c.backoff = Duration::from_millis(20);
c.max_backoff = Duration::from_millis(100);
c.limits = fast_limits();
c
}
fn join(invite: &Invite, name: &str) -> (Guest, Probe) {
let probe = Probe::new();
let g = Guest::start(quick(GuestConfig::new(invite.clone(), name.into())), probe.inbox.clone()).unwrap();
(g, probe)
}
fn presence(x: f64, y: f64) -> Presence {
Presence {
cursor: Some((NodeId(1), x, y)),
selection: Some(vec![NodeId(7)]),
viewport: Some((NodeId(1), 0.0, 0.0, 800.0, 600.0)),
editing_text: None,
}
}
/// A bare WebSocket client (sends what it's told, no throttling).
struct Raw(WebSocket<TcpStream>);
impl Raw {
fn connect(addr: SocketAddr) -> Raw {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
let (ws, _) = tungstenite::client::client(format!("ws://{addr}/"), s).unwrap();
Raw(ws)
}
fn send_bytes(&mut self, b: Vec<u8>) -> bool {
self.0.send(Message::Binary(Bytes::from(b))).is_ok()
}
fn send(&mut self, m: &ClientMsg) -> bool {
self.send_bytes(message::encode(m).unwrap())
}
fn hello(addr: SocketAddr, token: &str) -> Raw {
let mut r = Raw::connect(addr);
assert!(r.send(&Client::hello(FILE, token, "Raw", "")));
r
}
/// The next server message; `None` once the connection closed.
fn next(&mut self) -> Option<ServerMsg> {
loop {
match self.0.read() {
Ok(Message::Binary(b)) => return Some(message::decode(&b, 64 << 20).unwrap()),
Ok(Message::Close(_)) | Err(_) => return None,
Ok(_) => {}
}
}
}
/// Read until the connection ends; the refusal it got, if any.
fn refusal(&mut self) -> Option<String> {
let mut why = None;
while let Some(m) = self.next() {
if let ServerMsg::Refused { reason } = m {
why = Some(reason);
}
}
why
}
}
fn connections_settle(host: &Host, n: usize) {
let start = Instant::now();
while host.connections() != n {
assert!(start.elapsed() < WAIT, "{} connections, expected {n}", host.connections());
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn guests_join_and_see_each_other() {
let (mut host, mut hp, invite) = start();
let (_a, mut ap) = join(&invite, "Ada");
ap.wait("Ada's welcome and colour", |e| e.iter().filter(|e| matches!(e, Event::Welcome { .. })).count() >= 2);
let Some(Event::Welcome { info, peers }) = ap.events.iter().rev().find(|e| matches!(e, Event::Welcome { .. })) else { panic!("no welcome") };
assert_eq!(info.name, "Ada");
assert!(
ap.events.iter().any(|e| matches!(e, Event::Sync(ServerMsg::Welcome { snapshot, .. }) if !snapshot.is_empty())),
"the welcome carries the document for the sync client"
);
assert_eq!(peers.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Hana"], "the host is already here");
assert_eq!(peers[0].color, PEOPLE[0]);
assert_eq!(info.color, PEOPLE[1], "the host hands out the next free colour");
let ada = info.me;
assert_ne!(ada, host.me().client_id);
hp.wait("the host sees Ada", |_| true);
hp.wait("the host sees Ada", |e| e.iter().any(|e| matches!(e, Event::PeerJoined(p) if p.client_id == ada && p.name == "Ada")));
// A guest without a name gets one.
let (_b, mut bp) = join(&invite, "");
bp.wait("the second guest's colour", |e| e.iter().filter(|e| matches!(e, Event::Welcome { .. })).count() >= 2);
let Some(Event::Welcome { info, peers }) = bp.events.iter().rev().find(|e| matches!(e, Event::Welcome { .. })) else { panic!("no welcome") };
assert_eq!(info.name, "Guest 1");
assert_eq!(info.color, PEOPLE[2]);
assert_eq!(peers.len(), 2);
let b = info.me;
ap.wait("Ada sees the newcomer", |e| e.iter().any(|e| matches!(e, Event::PeerJoined(p) if p.client_id == b)));
assert!(!ap.joined().contains(&ada), "nobody is told about themselves as a peer");
host.stop();
assert_eq!(host.connections(), 0);
}
#[test]
fn a_wrong_token_or_file_is_refused() {
let (mut host, mut hp, invite) = start();
let (g, mut gp) = join(&Invite { token: "WrongWrongWrongWrong00".into(), ..invite.clone() }, "Eve");
gp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("token"))));
assert!(!welcomed(&gp.events));
let (_g2, mut gp2) = join(&Invite { file: "ffffffffffffffff".into(), ..invite.clone() }, "Eve");
gp2.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("another file"))));
// Nobody got in; the refused connections are closed.
hp.events.extend(hp.inbox.drain());
assert!(hp.joined().is_empty());
connections_settle(&host, 0);
let start = Instant::now();
while !g.finished() {
assert!(start.elapsed() < WAIT);
std::thread::sleep(Duration::from_millis(5));
}
// The secret never shows in what the UI is told.
assert!(gp.events.iter().all(|e| !format!("{e:?}").contains("WrongWrong")));
host.stop();
}
#[test]
fn unreachable_hosts_fail_at_once() {
// A port nothing listens on.
let free = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
let (_g, mut gp) = join(&Invite { host: free.to_string(), file: FILE.into(), token: TOKEN.into() }, "Ada");
gp.wait("a failure", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.starts_with("Couldn't join"))));
assert!(!gp.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a first join doesn't retry");
}
#[test]
fn presence_is_relayed_both_ways() {
let (mut host, mut hp, invite) = start();
let (a, mut ap) = join(&invite, "Ada");
let (_b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let ada = ap.me();
hp.wait("both joined", |e| e.iter().filter(|e| matches!(e, Event::PeerJoined(_))).count() == 2);
a.presence(presence(10.0, 20.0));
hp.wait("Ada's cursor at the host", |e| {
e.iter().any(|e| matches!(e, Event::Presence(id, p) if *id == ada && p.cursor == Some((NodeId(1), 10.0, 20.0))))
});
bp.wait("Ada's cursor at Bo", |e| e.iter().any(|e| matches!(e, Event::Presence(id, p) if *id == ada && p.selection == Some(vec![NodeId(7)]))));
ap.events.extend(ap.inbox.drain());
assert!(ap.presence_of(ada).is_none(), "your own presence doesn't come back");
// The host's own presence reaches the guests.
let hana = host.me().client_id;
host.presence(presence(5.0, 6.0));
ap.wait("the host's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, p) if *id == hana && p.cursor == Some((NodeId(1), 5.0, 6.0)))));
// Non-finite numbers never travel.
a.presence(Presence { cursor: Some((NodeId(1), f64::NAN, 1.0)), ..presence(0.0, 0.0) });
bp.wait("the sanitised cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, p) if *id == ada && p.cursor.is_none())));
host.stop();
}
#[test]
fn a_newcomer_sees_where_everyone_already_is() {
let (mut host, _hp, invite) = start();
let (a, mut ap) = join(&invite, "Ada");
ap.wait("welcome", welcomed);
let ada = ap.me();
a.presence(presence(42.0, 43.0));
host.presence(presence(1.0, 2.0));
std::thread::sleep(Duration::from_millis(150));
let (_b, mut bp) = join(&invite, "Bo");
let hana = host.me().client_id;
bp.wait("everyone's last presence", |e| {
let has = |who: u32, x: f64| e.iter().any(|e| matches!(e, Event::Presence(id, p) if *id == who && p.cursor.map(|c| c.1) == Some(x)));
has(ada, 42.0) && has(hana, 1.0)
});
host.stop();
}
#[test]
fn presence_is_throttled_and_the_latest_wins() {
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
let mut watcher = Raw::hello(addr, TOKEN);
assert!(matches!(watcher.next(), Some(ServerMsg::Welcome { .. })));
let mut sender = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: from, .. }) = sender.next() else { panic!("no welcome") };
// 200 updates as fast as the socket takes them.
let t0 = Instant::now();
for i in 0..200 {
assert!(sender.send(&ClientMsg::Presence(presence(f64::from(i), 0.0))));
}
let sent_in = t0.elapsed();
let mut got = Vec::new();
while got.last() != Some(&199.0) {
match watcher.next() {
Some(ServerMsg::Presence { client_id, presence }) if client_id == from => got.push(presence.cursor.unwrap().1),
Some(_) => {}
None => panic!("the watcher was disconnected"),
}
}
let span = t0.elapsed().max(sent_in);
let most = (span.as_secs_f64() / 0.033).ceil() as usize + 2;
assert!(got.len() <= most, "{} updates relayed in {span:?} (at most {most})", got.len());
assert!(got.len() < 200, "coalesced");
assert_eq!(got.last(), Some(&199.0), "the last update always arrives");
assert!(got.windows(2).all(|w| w[0] < w[1]), "in order");
host.stop();
}
#[test]
fn leaving_and_dropping_clean_up() {
let (mut host, mut hp, invite) = start();
let (mut a, mut ap) = join(&invite, "Ada");
let (_b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let ada = ap.me();
a.presence(presence(1.0, 1.0));
hp.wait("Ada's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == ada)));
connections_settle(&host, 2);
a.leave();
hp.wait("Ada left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
// A connection that just vanishes (no close) counts as leaving too.
let addr: SocketAddr = invite.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: rid, .. }) = raw.next() else { panic!("no welcome") };
drop(raw);
hp.wait("the dropped one left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == rid)));
connections_settle(&host, 1);
// A later newcomer doesn't see the leavers.
let (_c, mut cp) = join(&invite, "Cy");
cp.wait("welcome", welcomed);
let Some(Event::Welcome { peers, .. }) = cp.events.iter().find(|e| matches!(e, Event::Welcome { .. })) else { panic!("no welcome") };
assert_eq!(peers.len(), 2, "the host and Bo: {peers:?}");
assert!(hp.left().contains(&ada));
host.stop();
bp.wait("the end of the session", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == ENDED)));
assert_eq!(host.connections(), 0);
}
#[test]
fn a_full_session_refuses_more() {
let (mut host, _hp, invite) = start_with(fast_limits(), Limits { max_peers: 2, ..Limits::default() });
let (_a, mut ap) = join(&invite, "Ada");
ap.wait("welcome", welcomed);
let (_b, mut bp) = join(&invite, "Bo");
bp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("full"))));
host.stop();
}
#[test]
fn too_many_connections_are_turned_away() {
let (mut host, _hp, invite) = start_with(NetLimits { max_connections: 2, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let mut a = Raw::hello(addr, TOKEN);
let mut b = Raw::hello(addr, TOKEN);
assert!(a.next().is_some() && b.next().is_some());
// The third socket is closed before the handshake.
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
assert!(tungstenite::client::client(format!("ws://{addr}/"), s).is_err());
host.stop();
}
#[test]
fn oversized_messages_end_the_connection() {
let (mut host, mut hp, invite) = start_with(NetLimits { max_client_message: 4096, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
assert_eq!(raw.refusal().as_deref(), Some("message too large"));
hp.wait("it left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == client_id)));
// The host still serves others.
let (_a, mut ap) = join(&invite, "Ada");
ap.wait("welcome", welcomed);
host.stop();
}
#[test]
fn flooding_ends_the_connection() {
let (mut host, _hp, invite) = start_with(NetLimits { rate: 5.0, burst: 20.0, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
assert!(matches!(raw.next(), Some(ServerMsg::Welcome { .. })));
for _ in 0..100 {
if !raw.send(&ClientMsg::Ping) {
break;
}
}
assert_eq!(raw.refusal().as_deref(), Some("too many messages"));
host.stop();
}
#[test]
fn malformed_frames_are_refused() {
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
// Garbage instead of a hello, a wrong wire version, text, and something other than a hello.
type Case = (Box<dyn Fn(&mut Raw)>, &'static str);
let cases: Vec<Case> = vec![
(Box::new(|r| assert!(r.send_bytes(vec![1, 255, 255, 255, 255, 9]))), "that message couldn't be read"),
(
Box::new(|r| {
let mut b = message::encode(&Client::hello(FILE, TOKEN, "x", "")).unwrap();
b[0] = 9;
assert!(r.send_bytes(b));
}),
"that message couldn't be read",
),
(Box::new(|r| assert!(r.0.send(Message::Text("hello".into())).is_ok())), "only binary messages are spoken here"),
(Box::new(|r| assert!(r.send(&ClientMsg::Ping))), "say hello first"),
];
for (send, why) in cases {
let mut raw = Raw::connect(addr);
send(&mut raw);
assert_eq!(raw.refusal().as_deref(), Some(why));
}
// After joining, garbage still ends the connection with a refusal.
let mut raw = Raw::hello(addr, TOKEN);
assert!(matches!(raw.next(), Some(ServerMsg::Welcome { .. })));
assert!(raw.send_bytes(vec![1, 2, 200, 200, 200]));
assert_eq!(raw.refusal().as_deref(), Some("that message couldn't be read"));
connections_settle(&host, 0);
host.stop();
}
#[test]
fn random_frames_never_hurt_the_host() {
use proptest::strategy::{Strategy, ValueTree};
use proptest::test_runner::TestRunner;
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
let mut runner = TestRunner::deterministic();
let mut join_first = false;
let frames = proptest::collection::vec(proptest::collection::vec(proptest::num::u8::ANY, 0..200), 1..6);
for _ in 0..40 {
let tree = frames.new_tree(&mut runner).unwrap();
let mut raw = Raw::connect(addr);
// Sometimes join first, so the frames reach the session itself.
join_first = !join_first;
if join_first {
raw.send(&Client::hello(FILE, TOKEN, "Fuzz", ""));
}
for mut f in tree.current() {
if let Some(b) = f.first_mut() {
*b = 1;
}
if !raw.send_bytes(f) {
break;
}
}
drop(raw);
}
let (_a, mut ap) = join(&invite, "Ada");
ap.wait("welcome after the fuzzing", welcomed);
host.stop();
assert_eq!(host.connections(), 0);
}
#[test]
fn silent_connections_time_out() {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_millis(200), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
let t0 = Instant::now();
let mut buf = [0u8; 64];
// The host hangs up: end of stream or a reset.
assert!(!matches!(s.read(&mut buf), Ok(n) if n > 0));
assert!(t0.elapsed() < Duration::from_secs(5));
connections_settle(&host, 0);
host.stop();
}
#[test]
fn guests_reconnect_after_a_network_failure() {
let (mut host, mut hp, invite) = start();
let (g, mut gp) = join(&invite, "Ada");
gp.wait("welcome", welcomed);
let first = gp.me();
host.cut_connections();
gp.wait("reconnecting", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { attempt: 1 }))));
gp.wait("welcome again", |e| e.iter().filter(|e| matches!(e, Event::Welcome { .. })).count() >= 3);
let second = gp.me();
assert_ne!(first, second, "a new connection is a new client id");
hp.wait("the old one left and the new one joined", |e| {
e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == first))
&& e.iter().any(|e| matches!(e, Event::PeerJoined(p) if p.client_id == second))
});
// Presence flows again on the new connection.
g.presence(presence(9.0, 9.0));
hp.wait("the cursor again", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == second)));
host.stop();
gp.wait("the end", |e| e.iter().any(|e| matches!(e, Event::Ended { .. })));
}
#[test]
fn throttle_and_bucket() {
let t0 = Instant::now();
let ms = |n: u64| t0 + Duration::from_millis(n);
let mut t = Throttle::new(Duration::from_millis(33));
assert_eq!(t.offer(1, ms(0)), Some(1));
assert_eq!(t.offer(2, ms(10)), None);
assert_eq!(t.offer(3, ms(20)), None);
assert_eq!(t.due(), Some(ms(33)));
assert_eq!(t.take_due(ms(30)), None);
assert_eq!(t.take_due(ms(33)), Some(3), "the latest waiting value");
assert_eq!(t.due(), None);
assert_eq!(t.offer(4, ms(100)), Some(4));
let mut b = Bucket::new(10.0, 3.0);
let now = Instant::now();
assert!(b.take(now) && b.take(now) && b.take(now));
assert!(!b.take(now));
assert!(b.take(now + Duration::from_millis(150)), "refills at the rate");
}
#[test]
fn quiet_guests_stay_and_silent_connections_are_dropped() {
let limits = NetLimits { idle_timeout: Duration::from_millis(600), keepalive: Duration::from_millis(150), ..fast_limits() };
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
let probe = Probe::new();
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
cfg.limits = limits;
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
let mut gp = probe;
gp.wait("welcome", welcomed);
// A joined raw client that never says anything again.
let addr: SocketAddr = invite.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: silent, .. }) = raw.next() else { panic!("no welcome") };
hp.wait("the silent one is dropped", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == silent)));
std::thread::sleep(Duration::from_millis(1500));
gp.events.extend(gp.inbox.drain());
hp.events.extend(hp.inbox.drain());
let ada = gp.me();
assert!(!gp.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "pings keep a quiet guest in: {:?}", gp.events);
assert!(!hp.left().contains(&ada));
host.stop();
}

View File

@ -0,0 +1,137 @@
//! The WebSocket plumbing both ends share: one connection read by one thread and written by
//! another, limits, and rate limiting.
//!
//! Each connection has a reader thread blocked in `read` and a writer thread blocked on its
//! queue, so an idle session costs no CPU. Both sides of a connection are tungstenite state
//! machines over the same socket; every write goes through one lock and writes whole frames, so
//! frames from the two never interleave.
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use tungstenite::protocol::WebSocketConfig;
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NetLimits {
/// Sockets a host keeps open at once, joined or not.
pub max_connections: usize,
/// Largest message a client may send once it has joined (a batch can carry images), and
/// before: its hello.
pub max_client_message: usize,
pub max_hello: usize,
/// Largest message a client accepts from the host (a welcome carries the whole document).
pub max_server_message: usize,
/// Messages a client may send per second on average, and in a burst; beyond that it is
/// disconnected.
pub rate: f64,
pub burst: f64,
/// A new connection must finish the WebSocket handshake and say hello within this.
pub hello_timeout: Duration,
/// A connection with no traffic for this long is dead.
pub idle_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
/// How long a write may block.
pub write_timeout: Duration,
}
impl Default for NetLimits {
fn default() -> Self {
Self {
max_connections: 64,
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
max_hello: 64 << 10,
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
rate: 100.0,
burst: 400.0,
hello_timeout: Duration::from_secs(10),
idle_timeout: Duration::from_secs(45),
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
}
}
}
/// tungstenite's settings for messages up to `max` bytes.
pub fn config(max: usize) -> WebSocketConfig {
WebSocketConfig::default().max_message_size(Some(max)).max_frame_size(Some(max))
}
/// One side (read or write) of a connection: reads from its own handle, writes whole buffers
/// under the connection's write lock.
pub struct Duplex {
read: TcpStream,
write: Arc<Mutex<TcpStream>>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone() };
let b = Duplex { read: stream.try_clone()?, write };
Ok((a, b, stream))
}
}
impl Duplex {
/// How long a read on this handle may block (socket options are per handle: set them on the
/// one that reads).
pub fn set_read_timeout(&self, d: Option<Duration>) -> io::Result<()> {
self.read.set_read_timeout(d)
}
}
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.read.read(buf)
}
}
impl Write for Duplex {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write.lock().unwrap_or_else(PoisonError::into_inner).write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.write.lock().unwrap_or_else(PoisonError::into_inner).flush()
}
}
/// Close the socket both ways (wakes a reader blocked on it).
pub fn shutdown(s: &TcpStream) {
let _ = s.shutdown(Shutdown::Both);
}
/// A token bucket: `rate` per second on average, `burst` at once.
#[derive(Debug)]
pub struct Bucket {
rate: f64,
burst: f64,
tokens: f64,
at: Instant,
}
impl Bucket {
pub fn new(rate: f64, burst: f64) -> Self {
Self { rate, burst, tokens: burst, at: Instant::now() }
}
/// Spend one token at `now`; false when there are none left.
pub fn take(&mut self, now: Instant) -> bool {
let dt = now.saturating_duration_since(self.at).as_secs_f64();
self.at = now;
self.tokens = (self.tokens + dt * self.rate).min(self.burst);
if self.tokens < 1.0 {
return false;
}
self.tokens -= 1.0;
true
}
}

View File

@ -0,0 +1,214 @@
//! Two engine sessions in one process, linked over loopback through `collab.*` commands: hosting,
//! joining with the invite, presence in each other's live state, following, leaving.
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump both sessions (as their UIs would every frame) until `f` holds.
fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
let start = Instant::now();
loop {
a.live_poll();
b.live_poll();
a.live_sync();
b.live_sync();
if f(a, b) {
return;
}
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {} / {}", a.live_summary(), b.live_summary());
std::thread::sleep(Duration::from_millis(5));
}
}
fn host_and_join() -> (Session, Session, u32, u32) {
let mut host = session("Hana");
let mut guest = session("Ada");
let r = host.execute("collab.host", &json!({"port": 0})).unwrap();
assert_eq!(r["status"], "live");
assert_eq!(r["role"], "host");
assert_eq!(r["network"], false, "shared on this machine only by default");
let link = r["link"].as_str().unwrap().to_string();
assert!(link.starts_with("figcraft://join?host=127.0.0.1:"), "{link}");
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
assert_eq!(r["status"], "connecting");
pump(&mut host, &mut guest, "the join", |h, g| {
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
});
let hana = host.live.info.as_ref().unwrap().me;
let ada = guest.live.info.as_ref().unwrap().me;
(host, guest, hana, ada)
}
#[test]
fn host_join_and_see_each_other() {
let (mut host, mut guest, hana, ada) = host_and_join();
let g = guest.live_summary();
assert_eq!(g["role"], "guest");
assert_eq!(g["fileName"], "Untitled", "the host's file name");
assert_eq!(g["name"], "Ada");
assert_eq!(g["peers"][0]["name"], "Hana");
assert_eq!(host.live.peers[&ada].name, "Ada");
// The pointer: the guest's cursor shows up in the host's state.
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
});
// A selection travels too.
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let id = r["id"].as_u64().unwrap();
host.execute("select.set", &json!({"ids": [id]})).unwrap();
pump(&mut host, &mut guest, "the host's selection", |_, g| {
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
});
// Leaving: the host sees it, the guest is back to no session.
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
assert_eq!(guest.live.status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
host.execute("collab.stop", &json!({})).unwrap();
assert_eq!(host.live_summary()["status"], "off");
}
#[test]
fn following_a_viewport_until_you_move() {
let (mut host, mut guest, hana, ada) = host_and_join();
host.execute("collab.follow", &json!({"client": ada})).unwrap();
assert_eq!(host.live.follow, Some(ada));
// The guest pans and zooms: the host's view goes along.
{
let ds = guest.ds_mut().unwrap();
ds.view.cx = 5000.0;
ds.view.cy = -300.0;
ds.view.zoom = 2.0;
}
pump(&mut host, &mut guest, "the follow", |h, _| {
(h.ds().unwrap().view.cx - 5000.0).abs() < 1e-6 && (h.ds().unwrap().view.cy + 300.0).abs() < 1e-6
});
let (hv, gv) = (host.ds().unwrap().view.clone(), guest.ds().unwrap().view.clone());
assert!((hv.zoom - gv.zoom).abs() < 1e-9, "same size viewports, same zoom");
// The host pans: following stops, and later moves of the guest don't move the host.
host.ds_mut().unwrap().view.cx += 100.0;
host.live_sync();
assert_eq!(host.live.follow, None);
guest.ds_mut().unwrap().view.cx = -9000.0;
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
});
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
// Escape stops following too; following someone who isn't there fails.
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
guest.escape().unwrap();
assert_eq!(guest.live.follow, None);
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
// The followed peer leaving ends the follow.
host.execute("collab.follow", &json!({"client": ada})).unwrap();
guest.execute("collab.leave", &json!({})).unwrap();
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
}
#[test]
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
assert!(guest.live.peers.is_empty());
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
#[test]
fn a_bad_invite_is_reported() {
let mut guest = session("Ada");
let e = guest.execute("collab.join", &json!({"link": "figcraft://join?host=nope"})).unwrap_err().to_string();
assert!(e.contains("invite"), "{e}");
let mut host = session("Hana");
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
guest.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live.peers.is_empty());
}
/// The synced properties of two documents agree (nothing to send from one to the other).
fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
}
fn id_of(v: &Value) -> u64 {
v["id"].as_u64().unwrap()
}
#[test]
fn edits_sync_between_apps() {
let mut host = session("Hana");
host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
let link = host.execute("collab.host", &json!({"port": 0})).unwrap()["link"].as_str().unwrap().to_string();
let mut a = session("Ada");
let mut b = session("Bo");
a.execute("collab.join", &json!({"link": link})).unwrap();
b.execute("collab.join", &json!({"link": link})).unwrap();
let all = |host: &mut Session, a: &mut Session, b: &mut Session, what: &str, f: &dyn Fn(&Session, &Session, &Session) -> bool| {
let start = Instant::now();
loop {
for s in [&mut *host, &mut *a, &mut *b] {
s.live_poll();
s.live_sync();
}
if f(host, a, b) {
return;
}
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(5));
}
};
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
});
// The guests got the host's file as a new tab.
assert_eq!(a.docs.len(), 2);
assert!(same(host.doc().unwrap(), a.doc().unwrap()) && same(host.doc().unwrap(), b.doc().unwrap()));
// Ada creates, moves, recolours; makes and deletes an ellipse; Bo renames; the host adds text.
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap());
a.live_sync();
a.execute("node.set", &json!({"ids": [rect], "props": {"x": 300, "y": 120}})).unwrap();
a.live_sync();
a.execute("fill.set", &json!({"ids": [rect], "color": "#FF0000"})).unwrap();
let ellipse = id_of(&a.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 30, "height": 30})).unwrap());
a.live_sync();
a.execute("edit.delete", &json!({"ids": [ellipse]})).unwrap();
all(&mut host, &mut a, &mut b, "Ada's rectangle at Bo", &|_, _, b| b.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
b.execute("node.set", &json!({"ids": [rect], "props": {"name": "Card"}})).unwrap();
let text = id_of(&host.execute("text.create", &json!({"x": 10, "y": 300, "text": "Hello"})).unwrap());
all(&mut host, &mut a, &mut b, "everyone agreeing", &|h, a, b| {
let (hd, ad, bd) = (h.doc().unwrap(), a.doc().unwrap(), b.doc().unwrap());
same(hd, ad)
&& same(hd, bd)
&& ad.contains(figcraft_doc::NodeId(text))
&& hd.node(figcraft_doc::NodeId(rect)).is_some_and(|n| n.name == "Card")
});
let (hd, ad) = (host.doc().unwrap().clone(), a.doc().unwrap().clone());
let n = ad.node(figcraft_doc::NodeId(rect)).unwrap();
assert_eq!(n.name, "Card");
assert!(!ad.contains(figcraft_doc::NodeId(ellipse)), "the deleted ellipse is gone everywhere");
assert!(!b.doc().unwrap().contains(figcraft_doc::NodeId(ellipse)));
assert_eq!(hd.node(figcraft_doc::NodeId(rect)).unwrap().fills, n.fills, "the colour came across");
// And the server's: someone joining now gets its document in the welcome.
let mut c = session("Cy");
c.execute("collab.join", &json!({"link": link})).unwrap();
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
host.execute("collab.stop", &json!({})).unwrap();
}

View File

@ -0,0 +1,52 @@
//! Latest-wins throttling: at most one value per interval goes out; a value offered sooner waits
//! (replacing any older waiting value) until the interval has passed, so the last one is never
//! lost. Used for presence on both ends (about 30 updates a second).
use std::time::{Duration, Instant};
/// How often presence goes out at most.
pub const PRESENCE_INTERVAL: Duration = Duration::from_millis(33);
#[derive(Debug)]
pub struct Throttle<T> {
interval: Duration,
last: Option<Instant>,
pending: Option<T>,
}
impl<T> Throttle<T> {
pub fn new(interval: Duration) -> Self {
Self { interval, last: None, pending: None }
}
/// Offer `v` at `now`: returns it when it may go out now, else keeps it for [`take_due`](Self::take_due).
pub fn offer(&mut self, v: T, now: Instant) -> Option<T> {
if self.last.is_some_and(|l| now.saturating_duration_since(l) < self.interval) {
self.pending = Some(v);
return None;
}
self.last = Some(now);
self.pending = None;
Some(v)
}
/// When the waiting value may go out (none waiting: `None`).
pub fn due(&self) -> Option<Instant> {
self.pending.as_ref()?;
Some(self.last.map_or_else(Instant::now, |l| l + self.interval))
}
/// The waiting value, once its time has come.
pub fn take_due(&mut self, now: Instant) -> Option<T> {
if self.due().is_some_and(|d| d <= now) {
self.last = Some(now);
return self.pending.take();
}
None
}
}
/// The earliest of some deadlines.
pub fn earliest(times: impl IntoIterator<Item = Option<Instant>>) -> Option<Instant> {
times.into_iter().flatten().min()
}

View File

@ -0,0 +1,17 @@
//! Session secrets from the operating system's random number generator.
use base64::Engine as _;
/// A session token: 128 random bits, URL-safe base64 (22 characters).
pub fn secret() -> Result<String, String> {
let mut b = [0u8; 16];
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b))
}
/// A file's session id: 64 random bits as hex (not a secret; it tells sessions apart).
pub fn file_id() -> Result<String, String> {
let mut b = [0u8; 8];
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}

View File

@ -0,0 +1,30 @@
//! The browser build's stand-in: live sessions need the desktop app until the `web-sys`
//! WebSocket client lands (step 4 of `docs/collab-protocol.md`).
use figcraft_engine::live::{Event, HostOptions, LiveLink, Presence, SessionInfo};
use crate::inbox::Waker;
pub struct NetLink;
impl NetLink {
pub fn new(_wake: Waker) -> Self {
NetLink
}
}
impl LiveLink for NetLink {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
Err("hosting a live session needs the desktop app".into())
}
fn join(&mut self, link: &str, _: &str) -> Result<(), String> {
crate::invite::Invite::parse(link)?;
Err("joining live sessions from the browser comes in a later version; use the desktop app".into())
}
fn leave(&mut self) {}
fn presence(&mut self, _: &Presence) {}
fn send(&mut self, _: figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<Event> {
Vec::new()
}
}

View File

@ -30,6 +30,9 @@ pub static MENU: &[MenuItem] = &[
m!("File", "Save to version history…", "file.saveVersion"),
m!("File", "Show version history", "file.versionHistory"),
m!("File", SEP, ""),
// FigCraft's own (live sessions): not in the reference menu, so not counted by `parity`.
m!("File", "Join live session…", "collab.join"),
m!("File", SEP, ""),
m!("File", "Export…", "file.export"),
m!("File", "Export frames to PDF…", "file.exportFramesPdf"),
m!("File", "Export selection as SVG…", "file.exportSvg"),
@ -270,7 +273,7 @@ pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
let mut done = 0;
let mut missing = Vec::new();
let mut total = 0;
for m in items() {
for m in items().filter(|m| !m.id.starts_with("collab.")) {
total += 1;
if crate::find_command(m.id).is_some() || extra.contains(&m.id) {
done += 1;

View File

@ -95,6 +95,7 @@ fn registry() -> &'static Registry {
list.extend(crate::distance::specs());
list.extend(crate::collab::specs());
list.extend(crate::plugins::specs());
list.extend(crate::live::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {

View File

@ -25,6 +25,7 @@ pub mod guides;
pub mod inspect;
pub mod keymap;
pub mod library;
pub mod live;
pub mod measure;
pub mod params;
pub mod plugins;
@ -508,6 +509,8 @@ pub struct Session {
pub libraries: library::Cache,
/// Plugin modules loaded in this session and the last plugin run.
pub plugins: plugins::Runtime,
/// The live session (collaboration): status, peers, presence.
pub live: live::Live,
}
impl Default for Session {
@ -539,6 +542,7 @@ impl Session {
message: None,
libraries: library::Cache::default(),
plugins: plugins::Runtime::default(),
live: live::Live::default(),
}
}
@ -1099,6 +1103,8 @@ mod keymap_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod measure_tests;

769
crates/engine/src/live.rs Normal file
View File

@ -0,0 +1,769 @@
//! Live sessions (milestone M9, step 2 of `docs/collab-protocol.md`): people edit the same file
//! together and see each other's cursors, selections and viewports, and can follow one another.
//!
//! The engine holds what everyone sees ([`Live`]: status, peers and their presence, who you
//! follow) and the `collab.*` commands. The networking lives in a higher layer
//! (`figcraft-collab-net`), which the app installs as a [`LiveLink`]: the engine never opens a
//! socket, so nothing below the UI layer depends on networking. Without a link (the CLI, tests)
//! the commands say live sessions aren't available.
//!
//! Peers, presence and document changes are the wire types of the collaboration core
//! ([`figcraft_collab::message`]). Each participant runs a core [`Client`]: the host's session
//! document is the one it shared, a guest gets the host's file as a new tab from the welcome.
//! After every local edit (once a gesture ends) the session document is diffed into a batch and
//! sent; batches from the host's server are applied through the client and installed with
//! [`Session::apply_remote`], which never touches the undo history.
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::sync::Arc;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_doc::{Document, NodeId};
use serde::Serialize;
use serde_json::{Value, json};
pub use figcraft_collab::message::{Peer, Presence};
use crate::cmd::CommandSpec;
use crate::collab::EngineRecompute;
use crate::params::{get, num, req_string, string};
use crate::{DocState, EngineError, Result, Session, ViewState, bad, spec};
/// Most selected ids one presence update carries (the rest aren't shown to others).
pub const MAX_SELECTION: usize = 10_000;
/// Longest name shown to others (characters).
pub const MAX_NAME: usize = 64;
/// The colours people get in a session, in the order the host hands them out (`#RRGGBB`, white
/// text reads on each).
pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D9730D", "#E83E8C", "#00A3A3", "#665CE0"];
/// `#RRGGBB` → (r, g, b); `None` for anything else.
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some((byte(0)?, byte(2)?, byte(4)?))
}
/// The connection to a live session.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum Status {
/// Not in a session.
#[default]
Off,
/// Joining (connecting to the host, waiting for its welcome).
Connecting,
/// In the session.
Live,
/// The connection dropped; trying again (`attempt` from 1).
Reconnecting { attempt: u32 },
/// Couldn't join, or the connection was lost for good.
Failed { reason: String },
}
impl Status {
pub fn id(&self) -> &'static str {
match self {
Status::Off => "off",
Status::Connecting => "connecting",
Status::Live => "live",
Status::Reconnecting { .. } => "reconnecting",
Status::Failed { .. } => "failed",
}
}
/// In a session or on the way into one.
pub fn active(&self) -> bool {
matches!(self, Status::Connecting | Status::Live | Status::Reconnecting { .. })
}
/// What the Share popover says about it.
pub fn label(&self) -> String {
match self {
Status::Off => "Not in a live session".into(),
Status::Connecting => "Joining…".into(),
Status::Live => "Live".into(),
Status::Reconnecting { attempt } => format!("Connection lost, reconnecting (attempt {attempt})…"),
Status::Failed { reason } => reason.clone(),
}
}
}
/// Your part in the session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Role {
Host,
Guest,
}
/// A page of the shared file.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PageInfo {
pub id: u64,
pub name: String,
}
/// The session you're in.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub role: Role,
/// Your client id, name and colour as the others see them.
pub me: u32,
pub name: String,
pub color: String,
/// The shared file's name and pages (the host's).
pub file_name: String,
pub pages: Vec<PageInfo>,
/// The invite link (`figcraft://join?host=…&file=…&token=…`) and the same as a plain
/// WebSocket URL. Both carry the session's secret token: share them only with people you
/// invite.
pub link: String,
pub url: String,
/// `ip:port` the session listens on (host) or connects to (guest).
pub address: String,
/// Reachable from other machines (the host listens on a network address, not loopback).
pub network: bool,
}
/// What happened in the session since the last [`LiveLink::poll`].
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Status(Status),
/// Joined, or joined again after a reconnect: who you are and who else is here.
Welcome {
info: SessionInfo,
peers: Vec<Peer>,
},
PeerJoined(Peer),
PeerLeft(u32),
Presence(u32, Presence),
/// For the document sync client: the welcome (with the whole document), or a batch of
/// changes the server ordered, or the rejection of one of yours.
Sync(ServerMsg),
/// The host ended the session.
Ended {
reason: String,
},
}
/// Which network interface a hosted session listens on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bind {
/// This machine only (127.0.0.1): the default.
Local,
/// The machine's address on the local network (only when the user chooses to share there).
Lan,
/// A given interface address (e.g. a VPN's).
Addr(IpAddr),
}
/// What [`LiveLink::host`] needs to start a session for the open file.
#[derive(Clone, Debug)]
pub struct HostOptions {
/// `None`: the default port, or any free one when it's taken.
pub port: Option<u16>,
pub bind: Bind,
/// Your name (empty: the host picks one).
pub name: String,
/// The shared file (the session's authoritative copy starts from it).
pub doc: Arc<Document>,
}
/// The networking behind live sessions, installed by the app ([`Live::install`]). Calls return
/// at once; what happens later arrives through [`poll`](Self::poll).
pub trait LiveLink: Send {
/// Start hosting a session for `opts.doc` (ending any current one).
fn host(&mut self, opts: HostOptions) -> std::result::Result<SessionInfo, String>;
/// Start joining the session behind an invite link (ending any current one). Errors are for
/// links that can't be used; they never echo the link (it holds the token).
fn join(&mut self, link: &str, name: &str) -> std::result::Result<(), String>;
/// End the current session (stop hosting or leave).
fn leave(&mut self);
/// Your latest presence (the link throttles and coalesces).
fn presence(&mut self, p: &Presence);
/// A message for the server (document changes), sent in order.
fn send(&mut self, msg: ClientMsg);
/// Events since the last call; never blocks.
fn poll(&mut self) -> Vec<Event>;
}
/// The view a follow last applied: any other view means the user moved.
#[derive(Clone, Copy, Debug, PartialEq)]
struct FollowedView {
page: NodeId,
cx: f64,
cy: f64,
zoom: f64,
}
/// Live session state (see the module docs).
#[derive(Default)]
pub struct Live {
link: Option<Box<dyn LiveLink>>,
pub status: Status,
pub info: Option<SessionInfo>,
/// Everyone else in the session, by client id.
pub peers: BTreeMap<u32, Peer>,
/// Their latest presence (only while they're in the session).
pub presence: BTreeMap<u32, Presence>,
/// The peer whose viewport you follow.
pub follow: Option<u32>,
followed: Option<FollowedView>,
/// Your pointer over the canvas (page coordinates): the UI sets it, or `collab.cursor`.
pub cursor: Option<(f64, f64)>,
/// The presence last handed to the link.
sent: Option<Presence>,
/// The document sync client (from the welcome).
client: Option<Client>,
/// The open document the session syncs (its `DocState::uid`).
pub doc_uid: Option<u64>,
/// The session document as last sent or installed: a different one has local edits to send.
synced: Option<Arc<Document>>,
/// Server batches waiting for a gesture or preview on the session document to end.
deferred: Vec<ServerMsg>,
}
impl Live {
/// Install the networking (the app does this once at startup).
pub fn install(&mut self, link: Box<dyn LiveLink>) {
self.link = Some(link);
}
/// A link is installed: sessions can be hosted and joined.
pub fn available(&self) -> bool {
self.link.is_some()
}
pub fn role(&self) -> Option<Role> {
self.info.as_ref().map(|i| i.role)
}
/// Back to no session (keeps the link).
fn reset(&mut self) {
self.status = Status::Off;
self.info = None;
self.peers.clear();
self.presence.clear();
self.follow = None;
self.followed = None;
self.sent = None;
self.client = None;
self.doc_uid = None;
self.synced = None;
self.deferred.clear();
}
/// Document changes are syncing (the welcome arrived).
pub fn syncing(&self) -> bool {
self.client.is_some()
}
}
impl Drop for Live {
fn drop(&mut self) {
if let Some(l) = self.link.as_mut() {
l.leave();
}
}
}
/// The page a presence is on (its viewport's, else its cursor's).
pub fn presence_page(p: &Presence) -> Option<NodeId> {
p.viewport.map(|v| v.0).or(p.cursor.map(|c| c.0))
}
/// Presence ready to send: non-finite numbers and empty viewports dropped, the selection capped.
pub fn sanitize(mut p: Presence) -> Presence {
p.cursor = p.cursor.filter(|c| c.1.is_finite() && c.2.is_finite());
p.viewport = p.viewport.filter(|v| v.1.is_finite() && v.2.is_finite() && v.3.is_finite() && v.4.is_finite() && v.3 > 0.0 && v.4 > 0.0);
if let Some(s) = p.selection.as_mut() {
s.truncate(MAX_SELECTION);
}
p
}
/// The name others see: the name from Preferences, or empty for the default ("You"), which the
/// host replaces.
pub fn display_name(prefs: &crate::Prefs) -> String {
let n = prefs.user_name.trim();
if n == crate::cmd::comment::DEFAULT_AUTHOR { String::new() } else { n.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() }
}
/// Initials for an avatar ("Ada Lovelace" → "AL"; "?" for none).
pub fn initials(name: &str) -> String {
let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
if out.is_empty() {
out.push('?');
}
out.to_uppercase()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
/// A gesture, version preview or live text preview shows a document that isn't the edit to
/// sync yet.
fn busy(ds: &DocState) -> bool {
ds.interaction.is_some() || ds.preview.is_some() || ds.text_preview.is_some()
}
impl Session {
/// The active document is the session's synced document: page and layer ids in others'
/// presence refer to it.
pub fn live_shared(&self) -> bool {
self.live.client.is_some() && self.ds().is_ok_and(|ds| Some(ds.uid) == self.live.doc_uid)
}
/// Install a document that came from the session (others' edits): the selection, page and view
/// stay where they can, and the undo history isn't touched.
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
ds.doc = doc;
if !ds.doc.pages.contains(&ds.page) {
ds.page = ds.doc.first_page().unwrap_or(ds.page);
}
if ds.text_edit.as_ref().is_some_and(|t| !ds.doc.contains(t.id)) {
ds.text_edit = None;
}
if ds.vector_edit.as_ref().is_some_and(|v| !ds.doc.contains(v.id)) {
ds.vector_edit = None;
}
ds.clean_selection();
ds.revision += 1;
}
/// Your presence now: the session document's page, selection, viewport and edited text, and
/// the pointer (nothing while another document is in front).
pub fn live_presence(&self) -> Presence {
let Ok(ds) = self.ds() else { return Presence::default() };
if self.live.doc_uid.is_some_and(|u| u != ds.uid) {
return Presence::default();
}
let (x0, y0) = ds.view.origin();
let z = ds.view.zoom.max(1e-9);
sanitize(Presence {
cursor: self.live.cursor.map(|(x, y)| (ds.page, x, y)),
selection: Some(ds.selection.clone()),
viewport: Some((ds.page, x0, y0, ds.view.vw / z, ds.view.vh / z)),
editing_text: ds.text_edit.as_ref().map(|t| t.id),
})
}
/// Take in what happened in the session (the UI calls it every frame; the network wakes the
/// UI when something arrives). Returns whether anything changed.
pub fn live_poll(&mut self) -> bool {
let Some(link) = self.live.link.as_mut() else { return false };
let events = link.poll();
if events.is_empty() {
return false;
}
for e in events {
self.live_event(e);
}
self.live_deferred();
self.live_follow_apply();
true
}
fn live_event(&mut self, e: Event) {
let live = &mut self.live;
match e {
Event::Status(st) => {
match &st {
Status::Failed { reason } => {
self.message = Some(reason.clone());
live.reset();
}
Status::Off => live.reset(),
// Who's here comes again with the next welcome.
Status::Reconnecting { .. } => {
live.peers.clear();
live.presence.clear();
live.sent = None;
}
Status::Connecting | Status::Live => {}
}
live.status = st;
}
Event::Welcome { mut info, peers } => {
// The shared file's name and pages come with the document (the sync welcome).
if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) {
info.file_name = old.file_name.clone();
info.pages = old.pages.clone();
}
live.status = Status::Live;
live.info = Some(info);
live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect();
live.presence.retain(|id, _| live.peers.contains_key(id));
if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) {
live.follow = None;
live.followed = None;
}
// Others need your presence again.
live.sent = None;
}
Event::PeerJoined(p) => {
self.message = Some(format!("{} joined", p.name));
live.peers.insert(p.client_id, p);
}
Event::PeerLeft(id) => {
live.presence.remove(&id);
if let Some(p) = live.peers.remove(&id) {
self.message = Some(format!("{} left", p.name));
}
if live.follow == Some(id) {
live.follow = None;
live.followed = None;
}
}
Event::Presence(id, p) => {
if live.peers.contains_key(&id) {
live.presence.insert(id, p);
}
}
Event::Sync(ServerMsg::Welcome { .. }) => {
if let Event::Sync(w) = e {
self.live_welcome(w);
}
}
Event::Sync(msg) => {
live.deferred.push(msg);
}
Event::Ended { reason } => {
self.message = Some(reason);
live.reset();
}
}
}
/// The welcome: start the sync client; the host keeps editing the document it shared, a guest
/// opens the host's file as a new tab (or gets it again in that tab after a reconnect).
fn live_welcome(&mut self, welcome: ServerMsg) {
let client = match Client::join(welcome) {
Ok(c) => c,
Err(e) => return self.live_fail(format!("Couldn't open the shared file: {e}")),
};
let role = self.live.role();
let existing = self.live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u));
match (role, existing) {
(Some(Role::Host), Some(uid)) => {
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
let was_dirty = ds.is_dirty();
ds.doc_mut().set_client_id(client.id());
if !was_dirty {
ds.mark_saved();
}
}
// Edits made since hosting started go out with the first sync.
self.live.synced = None;
}
(Some(Role::Guest), Some(uid)) => {
let doc = Arc::new(client.document().clone());
self.apply_remote(uid, doc.clone());
self.live.synced = Some(doc);
}
(Some(Role::Guest), None) => {
let ds = DocState::new(client.document().clone(), None);
self.message = Some(format!("Joined {}", ds.doc.name));
self.live.doc_uid = Some(ds.uid);
self.live.synced = Some(ds.doc.clone());
self.docs.push(ds);
self.active = self.docs.len() - 1;
}
_ => return,
}
let doc = client.document();
if let Some(info) = self.live.info.as_mut() {
info.file_name = doc.name.clone();
info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
}
self.live.client = Some(client);
self.live.deferred.clear();
}
/// End the session with a message (sync broke).
fn live_fail(&mut self, reason: String) {
log::warn!("live session: {reason}");
if let Some(l) = self.live.link.as_mut() {
l.leave();
}
self.live.reset();
self.live.status = Status::Failed { reason: reason.clone() };
self.message = Some(reason);
}
/// Send the session document's local edits, unless a gesture or preview is still showing.
fn live_flush(&mut self) {
let Some(uid) = self.live.doc_uid else { return };
let Some(ds) = self.docs.iter().find(|d| d.uid == uid) else { return };
if busy(ds) || self.live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) {
return;
}
let doc = ds.doc.clone();
let Some(client) = self.live.client.as_mut() else { return };
match client.edit(&doc) {
Ok(Some(msg)) => {
if let Some(l) = self.live.link.as_mut() {
l.send(msg);
}
}
Ok(None) => {}
Err(e) => return self.live_fail(format!("Couldn't sync an edit: {e}")),
}
self.live.synced = Some(doc);
}
/// Apply the server's batches that waited (none while a gesture or preview shows).
fn live_deferred(&mut self) {
if self.live.deferred.is_empty() || self.live.client.is_none() {
return;
}
let Some(uid) = self.live.doc_uid else { return };
if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) {
return;
}
// Your own edits first: the client replays them over what others did.
self.live_flush();
let msgs = std::mem::take(&mut self.live.deferred);
let mut out = Vec::new();
let Some(client) = self.live.client.as_mut() else { return };
for m in msgs {
match client.receive(m, &EngineRecompute) {
Ok(more) => out.extend(more),
Err(e) => return self.live_fail(format!("The live session got out of step ({e}); join again")),
}
}
let doc = Arc::new(client.document().clone());
if let Some(l) = self.live.link.as_mut() {
for m in out {
l.send(m);
}
}
self.apply_remote(uid, doc.clone());
self.live.synced = Some(doc);
}
/// Show the followed peer's viewport (and their page, in a shared document).
fn live_follow_apply(&mut self) {
let Some(id) = self.live.follow else { return };
if !self.live.peers.contains_key(&id) {
self.live.follow = None;
self.live.followed = None;
return;
}
let Some((page, x, y, w, h)) = self.live.presence.get(&id).and_then(|p| p.viewport) else { return };
let shared = self.live_shared();
let Ok(ds) = self.ds_mut() else { return };
if shared && page != ds.page && ds.doc.pages.contains(&page) {
ds.page = page;
ds.selection.clear();
ds.text_edit = None;
}
if !(w > 0.0 && h > 0.0 && x.is_finite() && y.is_finite()) {
return;
}
ds.view.cx = x + w / 2.0;
ds.view.cy = y + h / 2.0;
let zoom = (ds.view.vw / w).min(ds.view.vh / h);
if zoom.is_finite() {
ds.view.zoom = zoom.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
}
ds.revision += 1;
self.live.followed = Some(FollowedView { page: ds.page, cx: ds.view.cx, cy: ds.view.cy, zoom: ds.view.zoom });
}
/// Send your presence when it changed (the UI calls it every frame it draws, after input;
/// headless sessions after each call). A view you moved yourself stops following.
pub fn live_sync(&mut self) {
if self.live.status != Status::Live {
return;
}
if self.live.doc_uid.is_some_and(|u| !self.docs.iter().any(|d| d.uid == u)) {
let reason = "The live session's file was closed, so you left the session".to_string();
if let Some(l) = self.live.link.as_mut() {
l.leave();
}
self.live.reset();
self.message = Some(reason);
return;
}
self.live_deferred();
self.live_flush();
if self.live.status != Status::Live {
return;
}
if let (Some(_), Some(f), Ok(ds)) = (self.live.follow, self.live.followed, self.ds())
&& (ds.page != f.page || !close(ds.view.cx, f.cx) || !close(ds.view.cy, f.cy) || !close(ds.view.zoom, f.zoom))
{
self.live.follow = None;
self.live.followed = None;
}
let p = self.live_presence();
if self.live.sent.as_ref() == Some(&p) {
return;
}
if let Some(link) = self.live.link.as_mut() {
link.presence(&p);
}
self.live.sent = Some(p);
}
/// Stop following (Escape, `collab.follow {}`). False when you weren't following anyone.
pub fn live_unfollow(&mut self) -> bool {
self.live.followed = None;
self.live.follow.take().is_some()
}
/// Follow `id`'s viewport (shown at once when known).
pub fn live_follow(&mut self, id: u32) -> Result<()> {
if !self.live.peers.contains_key(&id) {
return Err(bad("collab.follow", format!("nobody with client id {id} is in the session")));
}
self.live.follow = Some(id);
self.live.followed = None;
self.live_follow_apply();
Ok(())
}
/// The session as JSON (`collab.peers`).
pub fn live_summary(&self) -> Value {
let l = &self.live;
let info = l.info.as_ref();
let peers: Vec<Value> = l
.peers
.values()
.map(|p| json!({"id": p.client_id, "name": p.name, "color": p.color, "presence": l.presence.get(&p.client_id)}))
.collect();
json!({
"available": l.available(),
"status": l.status.id(),
"reason": match &l.status { Status::Failed { reason } => Some(reason.as_str()), _ => None },
"attempt": match l.status { Status::Reconnecting { attempt } => Some(attempt), _ => None },
"role": info.map(|i| i.role),
"me": info.map(|i| i.me),
"name": info.map(|i| i.name.as_str()),
"color": info.map(|i| i.color.as_str()),
"fileName": info.map(|i| i.file_name.as_str()),
"pages": info.map(|i| &i.pages),
"link": info.map(|i| i.link.as_str()),
"url": info.map(|i| i.url.as_str()),
"address": info.map(|i| i.address.as_str()),
"network": info.map(|i| i.network),
"follow": l.follow,
"syncing": l.syncing(),
"document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)),
"peers": peers,
})
}
}
fn unavailable(cmd: &str) -> EngineError {
EngineError::Disabled(cmd.to_string(), "live sessions aren't available here (the desktop app and the MCP server host and join them)".into())
}
fn host(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.host";
let port = match get(p, "port") {
None => None,
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(CMD, "`port` must be a whole number from 0 to 65535"))?),
};
let bind = match string(p, "bind").as_deref().map(str::trim) {
None | Some("local") => Bind::Local,
Some("lan") => Bind::Lan,
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(CMD, "`bind` must be \"local\", \"lan\" or an IP address"))?,
};
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc };
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
let info = link.host(opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}")))?;
s.live.reset();
s.live.status = Status::Live;
s.live.info = Some(info);
s.live.doc_uid = Some(uid);
Ok(s.live_summary())
}
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.join";
let link_text = req_string(CMD, p, "link")?;
let name = display_name(&s.prefs);
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
link.join(link_text.trim(), &name).map_err(|e| bad(CMD, e))?;
s.live.reset();
s.live.status = Status::Connecting;
Ok(s.live_summary())
}
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.live.status.active();
if let Some(l) = s.live.link.as_mut() {
l.leave();
}
s.live.reset();
Ok(json!({"ended": was}))
}
fn follow(s: &mut Session, p: &Value) -> Result<Value> {
match get(p, "client") {
None => {
s.live_unfollow();
}
Some(v) => {
let id = v.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad("collab.follow", "`client` must be a client id"))?;
s.live_follow(id)?;
}
}
Ok(json!({"follow": s.live.follow}))
}
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
s.live.cursor = match (num(p, "x"), num(p, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Err(bad("collab.cursor", "give both `x` and `y` (page coordinates), or neither to hide the cursor")),
};
s.live_sync();
Ok(json!({"cursor": s.live.cursor.map(|(x, y)| [x, y])}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"collab.host",
"Start live session",
"{port? (default 47900, else any free port), bind?: \"local\" (this machine, default) | \"lan\" (the local network) | an IP address} → the session: {link, url (invites: they carry the secret token), address, …}",
false,
host
),
spec!("collab.stop", "Stop live session", "{} (also leaves a joined session) → {ended}", false, leave),
spec!(
"collab.join",
"Join live session",
"{link: figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…} → status \"connecting\"; `collab.peers` tells when it's live",
false,
join
),
spec!("collab.leave", "Leave live session", "{} → {ended}", false, leave),
spec!(
"collab.peers",
"People in this file",
"{} → {status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
false,
|s, _| Ok(s.live_summary())
),
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
spec!(
"collab.cursor",
"Show cursor at",
"{x?, y? (page coordinates on the current page; omit both to hide)} (agents; in the app the pointer drives it)",
false,
cursor
),
]
}

View File

@ -0,0 +1,395 @@
//! Live session state and the `collab.*` commands against a scripted link (the real one, over
//! the network, is tested in `figcraft-collab-net`).
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use serde_json::json;
use crate::live::{Event, HostOptions, LiveLink, Peer, Presence, Role, SessionInfo, Status};
use crate::{EngineError, Session};
/// What the scripted link was asked to do, and the events it hands out next.
#[derive(Default)]
struct Script {
events: Vec<Event>,
sent: Vec<Presence>,
hosted: Vec<HostOptions>,
joined: Vec<String>,
left: usize,
msgs: Vec<ClientMsg>,
}
struct Mock(Arc<Mutex<Script>>);
impl LiveLink for Mock {
fn host(&mut self, opts: HostOptions) -> Result<SessionInfo, String> {
self.0.lock().unwrap().hosted.push(opts);
Ok(info(Role::Host, 1))
}
fn join(&mut self, link: &str, _: &str) -> Result<(), String> {
if !link.starts_with("figcraft://") {
return Err("not an invite".into());
}
self.0.lock().unwrap().joined.push(link.to_string());
Ok(())
}
fn leave(&mut self) {
self.0.lock().unwrap().left += 1;
}
fn presence(&mut self, p: &Presence) {
self.0.lock().unwrap().sent.push(p.clone());
}
fn send(&mut self, msg: ClientMsg) {
self.0.lock().unwrap().msgs.push(msg);
}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.0.lock().unwrap().events)
}
}
fn info(role: Role, me: u32) -> SessionInfo {
SessionInfo {
role,
me,
name: "Me".into(),
color: "#0D99FF".into(),
file_name: "Shared".into(),
pages: vec![],
link: "figcraft://join?host=127.0.0.1:47900&file=f&token=t".into(),
url: "ws://127.0.0.1:47900/?file=f&token=t".into(),
address: "127.0.0.1:47900".into(),
network: false,
}
}
fn peer(id: u32, name: &str) -> Peer {
Peer { client_id: id, name: name.into(), color: "#9747FF".into() }
}
fn session() -> (Session, Arc<Mutex<Script>>) {
let mut s = Session::new();
let script = Arc::new(Mutex::new(Script::default()));
s.live.install(Box::new(Mock(script.clone())));
(s, script)
}
fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
script.lock().unwrap().events.extend(events);
}
/// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`).
fn serve(doc: &Document) -> (Server, u32, Event) {
let mut server = Server::new(doc.clone(), "", Limits::default());
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
let (id, outs) = server.join(h).unwrap();
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
(server, id, Event::Sync(w))
}
/// Feed what the session sent to the server; the server's answers for the session, in order.
fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
let msgs = std::mem::take(&mut script.lock().unwrap().msgs);
let mut out = Vec::new();
for m in msgs {
for o in server.receive(from, m) {
match o {
Outgoing::To(id, m) | Outgoing::Others(id, m) if id == from && !matches!(m, ServerMsg::Presence { .. }) => out.push(Event::Sync(m)),
Outgoing::All(m) => out.push(Event::Sync(m)),
_ => {}
}
}
}
out
}
fn viewport(page: NodeId, x: f64, y: f64, w: f64, h: f64) -> Presence {
Presence { viewport: Some((page, x, y, w, h)), ..Presence::default() }
}
#[test]
fn without_a_link_live_sessions_are_unavailable() {
let mut s = Session::new();
for cmd in ["collab.host", "collab.join"] {
let e = s.execute(cmd, &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap_err();
assert!(matches!(e, EngineError::Disabled(..)), "{cmd}: {e}");
}
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], false);
let p = s.execute("collab.peers", &json!({})).unwrap();
assert_eq!(p["available"], false);
assert_eq!(p["status"], "off");
}
#[test]
fn hosting_and_joining_drive_the_link() {
let (mut s, script) = session();
let r = s.execute("collab.host", &json!({"bind": "lan", "port": 50000})).unwrap();
assert_eq!(r["status"], "live");
assert_eq!(r["role"], "host");
assert_eq!(r["link"], "figcraft://join?host=127.0.0.1:47900&file=f&token=t");
{
let sc = script.lock().unwrap();
assert_eq!(sc.hosted.len(), 1);
assert_eq!(sc.hosted[0].port, Some(50000));
assert_eq!(sc.hosted[0].bind, crate::live::Bind::Lan);
assert_eq!(sc.hosted[0].doc.name, "Untitled");
assert_eq!(sc.hosted[0].name, "", "the default name lets the host pick one");
}
for bad in [json!({"bind": "everywhere"}), json!({"port": 70000}), json!({"port": -1}), json!({"port": "x"})] {
assert!(matches!(s.execute("collab.host", &bad), Err(EngineError::BadParams { .. })), "{bad}");
}
assert!(s.execute("collab.host", &json!({"bind": "10.1.2.3"})).is_ok());
assert!(matches!(s.execute("collab.join", &json!({"link": "http://x"})), Err(EngineError::BadParams { .. })));
let r = s.execute("collab.join", &json!({"link": " figcraft://join?host=a:1&file=f&token=t "})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(script.lock().unwrap().joined, ["figcraft://join?host=a:1&file=f&token=t"]);
assert_eq!(s.execute("collab.stop", &json!({})).unwrap()["ended"], true);
assert!(script.lock().unwrap().left >= 1);
assert_eq!(s.live.status, Status::Off);
}
#[test]
fn events_build_who_is_here() {
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }, Event::PeerJoined(peer(3, "Bo"))]);
assert!(s.live_poll());
assert_eq!(s.live.status, Status::Live);
assert_eq!(s.live.peers.len(), 2);
assert_eq!(s.message.as_deref(), Some("Bo joined"));
// Presence of someone not in the session is ignored; a leaver's presence goes with them.
push(&script, vec![Event::Presence(9, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0)), Event::Presence(3, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0))]);
s.live_poll();
assert_eq!(s.live.presence.keys().copied().collect::<Vec<_>>(), [3]);
push(&script, vec![Event::PeerLeft(3)]);
s.live_poll();
assert!(s.live.presence.is_empty());
assert_eq!(s.message.as_deref(), Some("Bo left"));
let p = s.live_summary();
assert_eq!(p["peers"][0]["name"], "Hana");
assert_eq!(p["fileName"], "Shared");
// Reconnecting forgets who's here until the next welcome; failing ends it all.
push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]);
s.live_poll();
assert!(s.live.peers.is_empty());
assert_eq!(s.live_summary()["attempt"], 1);
push(&script, vec![Event::Status(Status::Failed { reason: "Lost the connection".into() })]);
s.live_poll();
assert!(s.live.info.is_none());
assert_eq!(s.live_summary()["reason"], "Lost the connection");
assert_eq!(s.message.as_deref(), Some("Lost the connection"));
assert!(!s.live_poll(), "nothing new");
}
#[test]
fn presence_is_sent_when_it_changes_and_only_when_live() {
let (mut s, script) = session();
s.live.cursor = Some((10.0, 20.0));
s.live_sync();
assert!(script.lock().unwrap().sent.is_empty(), "not in a session");
s.execute("collab.host", &json!({})).unwrap();
s.live_sync();
s.live_sync();
let sent = script.lock().unwrap().sent.clone();
assert_eq!(sent.len(), 1, "unchanged presence isn't sent again");
let page = s.page().unwrap();
assert_eq!(sent[0].cursor, Some((page, 10.0, 20.0)));
assert_eq!(sent[0].selection, Some(vec![]));
let v = sent[0].viewport.unwrap();
assert_eq!(v.0, page);
assert!((v.3 - 1200.0).abs() < 1e-9 && (v.4 - 800.0).abs() < 1e-9, "the viewport at 100%: {v:?}");
// `collab.cursor` moves it (agents); a non-finite pointer never goes out.
s.execute("collab.cursor", &json!({"x": 1, "y": 2})).unwrap();
assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, Some((page, 1.0, 2.0)));
assert!(s.execute("collab.cursor", &json!({"x": 1})).is_err());
s.live.cursor = Some((f64::NAN, 0.0));
s.live_sync();
assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, None);
s.execute("collab.cursor", &json!({})).unwrap();
assert_eq!(s.live.cursor, None);
}
#[test]
fn following_shows_their_viewport_and_page() {
let (mut s, script) = session();
s.execute("collab.host", &json!({})).unwrap();
let second = s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
let first = s.doc().unwrap().pages[0];
s.execute("page.select", &json!({"id": first.0})).unwrap();
push(&script, vec![Event::PeerJoined(peer(5, "Ada")), Event::Presence(5, viewport(second, 100.0, 200.0, 600.0, 400.0))]);
s.live_poll();
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err());
// Before the document syncs, their page ids aren't this document's: only the viewport counts.
s.execute("collab.follow", &json!({"client": 5})).unwrap();
assert_eq!(s.ds().unwrap().page, first);
assert_eq!(s.ds().unwrap().view.cx, 400.0);
let (_server, _, welcome) = serve(s.doc().unwrap());
push(&script, vec![welcome]);
s.live_poll();
assert!(s.live_shared());
s.execute("collab.follow", &json!({"client": 5})).unwrap();
let ds = s.ds().unwrap();
assert_eq!(ds.page, second, "their page, in a shared document");
assert_eq!((ds.view.cx, ds.view.cy), (400.0, 400.0));
assert!((ds.view.zoom - 2.0).abs() < 1e-9, "their area fills the viewport");
// A page this document doesn't have: only the viewport.
push(&script, vec![Event::Presence(5, viewport(NodeId(987_654), 0.0, 0.0, 1200.0, 800.0))]);
s.live_poll();
assert_eq!(s.ds().unwrap().page, second);
assert_eq!(s.ds().unwrap().view.cx, 600.0);
// Zooming yourself stops following.
s.ds_mut().unwrap().view.zoom = 3.0;
s.live_sync();
assert_eq!(s.live.follow, None);
// So does Escape, before anything else it does.
s.execute("collab.follow", &json!({"client": 5})).unwrap();
s.execute("select.all", &json!({})).ok();
s.escape().unwrap();
assert_eq!(s.live.follow, None);
// And `collab.follow {}`.
s.execute("collab.follow", &json!({"client": 5})).unwrap();
assert_eq!(s.execute("collab.follow", &json!({})).unwrap()["follow"], serde_json::Value::Null);
}
#[test]
fn the_host_ending_resets_everything() {
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }]);
s.live_poll();
s.execute("collab.follow", &json!({"client": 1})).unwrap();
push(&script, vec![Event::Ended { reason: "The host ended the session".into() }]);
s.live_poll();
assert_eq!(s.live.status, Status::Off);
assert!(s.live.peers.is_empty() && s.live.follow.is_none() && s.live.info.is_none());
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
}
#[test]
fn collab_commands_survive_junk() {
let junk = [
json!(null),
json!([1, 2]),
json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}}),
json!({"link": "", "client": 4_294_967_296u64}),
];
for cmd in crate::all_commands().iter().filter(|c| c.id.starts_with("collab.")) {
for j in &junk {
let (mut s, _) = session();
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd.id, j) {
panic!("{} panicked on {j}: {msg}", cmd.id);
}
}
}
}
#[test]
fn edits_sync_through_the_server_and_remote_ones_skip_undo() {
let (mut s, script) = session();
s.execute("collab.host", &json!({})).unwrap();
let (mut server, me, welcome) = serve(s.doc().unwrap());
push(&script, vec![welcome]);
s.live_poll();
assert!(s.live.syncing() && s.live_shared());
assert!(!s.ds().unwrap().is_dirty(), "starting a session doesn't dirty the file");
// An edit made while hosting goes out as one batch, and the server confirms it.
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let rect = NodeId(r["id"].as_u64().unwrap());
assert_eq!(rect.0 >> 32, u64::from(me), "new layers take ids in this client's space");
s.live_sync();
assert_eq!(script.lock().unwrap().msgs.len(), 1);
s.live_sync();
assert_eq!(script.lock().unwrap().msgs.len(), 1, "nothing new, nothing sent");
let back = relay(&script, &mut server, me);
assert!(server.document().contains(rect), "the server has the rectangle");
push(&script, back);
s.live_poll();
// A gesture is sent once, when it ends.
s.begin_interaction("Move").unwrap();
s.preview(|d, _| {
if let Some(n) = d.node_mut(rect) {
n.opacity = 0.5;
}
Ok(())
})
.unwrap();
s.live_sync();
assert!(script.lock().unwrap().msgs.is_empty(), "nothing while dragging");
s.commit_interaction().unwrap();
s.live_sync();
assert_eq!(script.lock().unwrap().msgs.len(), 1);
push(&script, relay(&script, &mut server, me));
s.live_poll();
// Someone else recolours it: applied, with no undo step of its own.
let undo_before = s.ds().unwrap().history.undo.len();
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
let (ada, outs) = server.join(h).unwrap();
let Some(Outgoing::To(_, w)) = outs.into_iter().find(|o| matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))) else { panic!("no welcome") };
let mut other = Client::join(w).unwrap();
let mut doc = other.document().clone();
doc.node_mut(rect).unwrap().opacity = 0.25;
let batch = other.edit(&doc).unwrap().unwrap();
let mut remote = Vec::new();
for o in server.receive(ada, batch) {
if let Outgoing::All(m) = o {
remote.push(Event::Sync(m));
}
}
push(&script, remote);
assert!(s.live_poll());
assert_eq!(s.doc().unwrap().node(rect).unwrap().opacity, 0.25);
assert_eq!(s.ds().unwrap().history.undo.len(), undo_before, "remote edits never enter your undo history");
assert_eq!(s.doc().unwrap().node(rect), server.document().node(rect), "the same as the server");
// While a gesture shows, remote batches wait for it to end.
let mut doc = other.document().clone();
doc.node_mut(rect).unwrap().name = "Card".into();
let batch = other.edit(&doc).unwrap().unwrap();
let remote: Vec<Event> =
server.receive(ada, batch).into_iter().filter_map(|o| if let Outgoing::All(m) = o { Some(Event::Sync(m)) } else { None }).collect();
s.begin_interaction("Move").unwrap();
push(&script, remote);
s.live_poll();
assert_ne!(s.doc().unwrap().node(rect).unwrap().name, "Card");
s.cancel_interaction().unwrap();
s.live_sync();
assert_eq!(s.doc().unwrap().node(rect).unwrap().name, "Card");
// Closing the session's file leaves the session.
s.execute("file.new", &json!({})).unwrap();
let uid = s.live.doc_uid.unwrap();
let i = s.docs.iter().position(|d| d.uid == uid).unwrap();
s.execute("file.close", &json!({"index": i, "force": true})).unwrap();
s.live_sync();
assert_eq!(s.live.status, Status::Off);
}
#[test]
fn a_guest_opens_the_hosts_file_as_a_new_tab() {
let (mut s, script) = session();
let mut host_doc = Document::new("Team file");
host_doc.repair();
let (mut server, _, _) = serve(&host_doc);
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
let (ada, outs) = server.join(h).unwrap();
let Some(Outgoing::To(_, w)) = outs.into_iter().find(|o| matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))) else { panic!("no welcome") };
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, ada), peers: vec![peer(1, "Hana")] }, Event::Sync(w)]);
s.live_poll();
assert_eq!(s.docs.len(), 2, "your own file stays open");
assert_eq!(s.doc().unwrap().name, "Team file");
assert_eq!(s.live_summary()["fileName"], "Team file");
assert_eq!(s.message.as_deref(), Some("Joined Team file"));
assert!(s.live_shared());
// Your edit reaches the server.
let r = s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.live_sync();
let back = relay(&script, &mut server, ada);
assert!(server.document().contains(NodeId(r["id"].as_u64().unwrap())));
push(&script, back);
s.live_poll();
// Leaving keeps the file as a local copy.
s.execute("collab.leave", &json!({})).unwrap();
assert_eq!(s.docs.len(), 2);
assert!(s.doc().unwrap().contains(NodeId(r["id"].as_u64().unwrap())));
}

View File

@ -1779,6 +1779,10 @@ impl Session {
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the
/// focus view, once the focused frame or nothing is selected, it ends the focus view).
pub fn escape(&mut self) -> Result<()> {
// Following someone in a live session: Escape stops that first.
if self.live_unfollow() {
return Ok(());
}
if self.tool.pen.is_some() {
return self.pen_finish();
}

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
figcraft-engine = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-geom = { workspace = true }
figcraft-render = { workspace = true }

View File

@ -37,16 +37,23 @@ fn ids_of(p: &Value) -> Vec<NodeId> {
impl Headless {
/// A session with one new, empty document.
pub fn new() -> Self {
Self { session: Session::new() }
Self::with_session(Session::new())
}
/// Wrap an existing session.
pub fn with_session(session: Session) -> Self {
/// Wrap an existing session (it can host and join live sessions: an agent takes part with
/// its own cursor, `collab.cursor`).
pub fn with_session(mut session: Session) -> Self {
if !session.live.available() {
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(|| {}))));
}
Self { session }
}
fn execute(&mut self, cmd: &str, params: &Value) -> Result<Value, String> {
// What happened in a live session since the last call (latest presence only).
self.session.live_poll();
let r = self.session.execute(cmd, params).map_err(|e| e.to_string());
self.session.live_sync();
// No UI: dialogs and panels requested by commands are dropped.
self.session.ui_requests.clear();
r

View File

@ -139,6 +139,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
input(app, ui, &resp);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
request_render(app, worker, ppp);
// Every finished render is shown, even when a newer one is already on its way: renders finish
@ -191,6 +192,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::dev_mode::draw(app, &painter);
crate::handles::draw(app, &painter);
crate::comments::draw(app, &painter);
crate::live::draw(app, &painter, &ctx);
crate::comments::popovers(app, &ctx);
crate::versions::preview_banner(app, &ctx, rect);
crate::dev_mode::focus_bar(app, &ctx, rect);
@ -478,7 +480,7 @@ fn stroke(c: Color32, w: f32) -> Stroke {
}
/// A node's oriented outline in screen space.
fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos2; 4]> {
pub(crate) fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos2; 4]> {
let n = doc.node(id)?;
let w = doc.world_transform(id);
let corners = [Point::new(0.0, 0.0), Point::new(n.width, 0.0), Point::new(n.width, n.height), Point::new(0.0, n.height)];

View File

@ -304,31 +304,12 @@ fn cluster_rect(at: Pos2, n: usize) -> Rect {
/// A stable colour per author.
pub fn author_color(name: &str) -> Color32 {
const PALETTE: [Color32; 8] = [
Color32::from_rgb(0x0D, 0x99, 0xFF),
Color32::from_rgb(0x97, 0x47, 0xFF),
Color32::from_rgb(0x14, 0xAE, 0x5C),
Color32::from_rgb(0xF2, 0x48, 0x22),
Color32::from_rgb(0xFF, 0xA6, 0x29),
Color32::from_rgb(0xE8, 0x3E, 0x8C),
Color32::from_rgb(0x00, 0xA3, 0xA3),
Color32::from_rgb(0x66, 0x5C, 0xE0),
];
let h = name.bytes().fold(5381u32, |h, b| h.wrapping_mul(33) ^ u32::from(b));
PALETTE.get(h as usize % PALETTE.len()).copied().unwrap_or(ACCENT)
}
fn initials(name: &str) -> String {
let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
if out.is_empty() {
out.push('?');
}
out.to_uppercase()
crate::live::people(h as usize)
}
fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str) {
painter.circle_filled(center, r, author_color(name));
painter.text(center, Align2::CENTER_CENTER, initials(name), semibold(r * 0.8), Color32::WHITE);
crate::live::avatar(painter, center, r, name, author_color(name));
}
/// Seconds since `t` (Unix seconds); `None` for unknown or future times.
@ -373,7 +354,7 @@ fn paint_pin(painter: &egui::Painter, r: Rect, p: &Pin, hot: bool) {
painter.rect_stroke(r, BUBBLE, Stroke::new(1.0, Color32::from_black_alpha(30)), StrokeKind::Inside);
let fill = if p.resolved { Color32::from_gray(150) } else { author_color(&p.author) };
painter.circle_filled(r.center(), 11.0, fill);
painter.text(r.center(), Align2::CENTER_CENTER, initials(&p.author), semibold(9.0), Color32::WHITE);
painter.text(r.center(), Align2::CENTER_CENTER, figcraft_engine::live::initials(&p.author), semibold(9.0), Color32::WHITE);
if p.unread {
let c = r.right_top() + vec2(-2.0, 2.0);
painter.circle_filled(c, 4.5, Color32::WHITE);

View File

@ -201,6 +201,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
color_profile_dialog(app, ctx);
crate::plugins_ui::show(app, ctx);
crate::live::join_dialog(app, ctx);
}
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the

View File

@ -29,6 +29,7 @@ pub mod icons;
pub mod layers;
pub mod layout_panel;
pub mod list_drag;
pub mod live;
pub mod measure_overlay;
pub mod menus;
pub mod plugins_ui;
@ -72,6 +73,8 @@ mod layers_keys_tests;
#[cfg(test)]
mod layers_rename_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod panel_reorder_tests;
#[cfg(test)]
mod perf_tests;
@ -275,6 +278,8 @@ pub struct UiState {
pub plugins_window: bool,
/// A plugin command's parameters form, when open.
pub plugin_form: Option<plugins_ui::PluginForm>,
/// Live sessions: remote cursors as drawn, the Join dialog.
pub live: live::LiveUi,
}
impl Default for UiState {
@ -342,6 +347,7 @@ impl Default for UiState {
nav: a11y::Nav::default(),
plugins_window: false,
plugin_form: None,
live: live::LiveUi::default(),
}
}
}
@ -645,6 +651,12 @@ impl FigApp {
let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx);
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
// What happened in a live session (the network woke us for it).
if self.session.live_poll()
&& let Some(m) = self.session.message.take()
{
self.toast(m);
}
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
a11y::begin(self, &ctx);
if self.ui.title_bar == TitleBar::Custom {
@ -741,6 +753,8 @@ impl FigApp {
}
a11y::end(self, &ctx);
self.field_focus = a11y::field_focused(&ctx);
// Your pointer, selection and view to the others in a live session (when they changed).
self.session.live_sync();
#[cfg(not(target_arch = "wasm32"))]
{
self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0;
@ -819,6 +833,7 @@ impl FigApp {
figcraft_render::Work::Full => json!({"kind": "full"}),
}),
},
"live": live::inspect(self),
"tool": self.session.tool_info(),
"status": self.session.status(),
"lastError": self.ui.last_error,

402
crates/ui-egui/src/live.rs Normal file
View File

@ -0,0 +1,402 @@
//! Live sessions in the window: other people's cursors (smoothly following their pointer, with a
//! name tag) and selections on the canvas (their edits arrive through the engine's sync), their avatars at the top of the right panel (click one
//! to follow their view), the Share popover (start a session, the invite link, stop) and the
//! Join dialog. Everything acts through the `collab.*` commands; the state is the engine's
//! (`Session::live`). The network wakes the UI when something arrives, and cursors only ask for
//! frames while they glide, so an idle session draws nothing.
use std::collections::BTreeMap;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use figcraft_engine::live::{PEOPLE, Role, Status, initials, parse_color, presence_page};
use serde_json::json;
use crate::FigApp;
use crate::a11y::{self, Tip};
use crate::icons::{self, Icon};
use crate::right::{menu_item, menu_item_with, menu_popup};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
/// Until undo is per person, it restores whole earlier states.
const UNDO_NOTE: &str = "Undo isn't per person yet: it can also revert others' recent edits.";
/// How fast a remote cursor closes in on where it was last reported (per second).
const GLIDE: f64 = 20.0;
/// Most of one person's selection outlined.
const MAX_OUTLINES: usize = 500;
/// UI-only live session state.
#[derive(Default)]
pub struct LiveUi {
/// Where each remote cursor is drawn (page coordinates), gliding to where it was reported.
shown: BTreeMap<u32, (f64, f64)>,
/// The UI clock at the last cursor frame.
last: f64,
/// The pointer position last handed to the session (page coordinates).
seen: Option<(f64, f64)>,
/// The Join dialog's link field (open when `Some`).
pub join: Option<String>,
/// Start sessions on the local network (else this machine only).
pub lan: bool,
}
impl LiveUi {
/// The remote cursors drawn last frame (`ui.inspect`).
pub fn shown(&self) -> Vec<u32> {
self.shown.keys().copied().collect()
}
}
/// A person's colour (`#RRGGBB` from the session), or the accent for anything else.
pub fn color(s: &str) -> Color32 {
parse_color(s).map_or(ACCENT, |(r, g, b)| Color32::from_rgb(r, g, b))
}
/// The palette colour `i` (comment authors use it too).
pub fn people(i: usize) -> Color32 {
PEOPLE.get(i % PEOPLE.len()).map_or(ACCENT, |c| color(c))
}
/// Text that reads on `fill`.
pub fn text_on(fill: Color32) -> Color32 {
let l = 0.299 * f32::from(fill.r()) + 0.587 * f32::from(fill.g()) + 0.114 * f32::from(fill.b());
if l > 170.0 { Color32::from_rgb(0x1E, 0x1E, 0x1E) } else { Color32::WHITE }
}
/// An avatar: the initials in a circle of the person's colour.
pub fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str, fill: Color32) {
painter.circle_filled(center, r, fill);
painter.text(center, Align2::CENTER_CENTER, initials(name), semibold(r * 0.8), text_on(fill));
}
/// FigCraft's remote pointer: a notched dart pointing up-left from `tip`, filled with the
/// person's colour and edged in white so it shows on any canvas.
pub fn pointer(painter: &egui::Painter, tip: Pos2, fill: Color32) {
let at = |x: f32, y: f32| tip + vec2(x, y);
let (left, notch, right) = (at(3.5, 15.5), at(7.5, 8.5), at(15.5, 4.0));
painter.add(Shape::convex_polygon(vec![tip, left, notch], fill, Stroke::NONE));
painter.add(Shape::convex_polygon(vec![tip, notch, right], fill, Stroke::NONE));
painter.add(Shape::closed_line(vec![tip, left, notch, right], Stroke::new(1.5, Color32::WHITE)));
}
/// The name tag next to a remote pointer.
fn name_tag(painter: &egui::Painter, tip: Pos2, name: &str, fill: Color32) {
let galley = painter.layout_no_wrap(name.to_string(), semibold(11.0), text_on(fill));
let r = Rect::from_min_size(tip + vec2(12.0, 16.0), galley.size() + vec2(12.0, 6.0));
painter.rect_filled(r, CornerRadius::same(4), fill);
painter.galley(r.min + vec2(6.0, 3.0), galley, text_on(fill));
}
/// Hand the pointer's page position to the session when it moved (or the view under it did).
pub fn track_pointer(app: &mut FigApp, ctx: &egui::Context) {
if app.session.live.status != Status::Live {
app.ui.live.seen = None;
return;
}
let at = ctx.input(|i| i.pointer.hover_pos()).filter(|p| app.canvas.rect.contains(*p)).map(|p| app.to_page(p)).map(|p| (p.x, p.y));
if at != app.ui.live.seen {
app.ui.live.seen = at;
app.session.live.cursor = at;
}
}
/// Others' selections and cursors on the canvas, and the frame around it while following.
pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) {
let live = &app.session.live;
if live.peers.is_empty() {
app.ui.live.shown.clear();
return;
}
let Ok(ds) = app.session.ds() else { return };
let (doc, page, shared) = (ds.doc.clone(), ds.page, app.session.live_shared());
let mut cursors = Vec::new();
for (id, p) in &live.presence {
let Some(peer) = live.peers.get(id) else { continue };
let fill = color(&peer.color);
// Selections only mean something on the same page of the same (synced) document.
if shared
&& presence_page(p) == Some(page)
&& let Some(sel) = &p.selection
{
for n in sel.iter().filter(|n| doc.page_of(**n) == Some(page)).take(MAX_OUTLINES) {
if let Some(q) = crate::canvas::node_quad(app, &doc, *n) {
painter.add(Shape::closed_line(q.to_vec(), Stroke::new(1.5, fill)));
}
}
}
// Cursors: on your page of the session's document; before its welcome arrives (or with
// another document in front) they show in page coordinates on whatever page you look at.
if let Some((pg, x, y)) = p.cursor
&& (pg == page || !shared)
{
cursors.push((*id, peer.name.clone(), fill, (x, y)));
}
}
if let Some(peer) = live.follow.and_then(|f| live.peers.get(&f)) {
follow_frame(painter, app.canvas.rect, &peer.name, color(&peer.color));
}
let now = ctx.input(|i| i.time);
let dt = (now - app.ui.live.last).clamp(0.0, 0.1);
app.ui.live.last = now;
let k = if app.reduce_motion() { 1.0 } else { 1.0 - (-dt * GLIDE).exp() };
let zoom = ds.view.zoom.max(1e-9);
let mut gliding = false;
let mut shown = BTreeMap::new();
for (id, name, fill, (x, y)) in cursors {
let (mut sx, mut sy) = app.ui.live.shown.get(&id).copied().unwrap_or((x, y));
sx += (x - sx) * k;
sy += (y - sy) * k;
// Within half a pixel: there.
if ((x - sx) * zoom).hypot((y - sy) * zoom) < 0.5 {
(sx, sy) = (x, y);
} else {
gliding = true;
}
shown.insert(id, (sx, sy));
let tip = app.to_screen(sx, sy);
pointer(painter, tip, fill);
name_tag(painter, tip, &name, fill);
}
app.ui.live.shown = shown;
if gliding {
ctx.request_repaint();
}
}
/// A frame around the canvas in the followed person's colour, with their name at the top.
fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32) {
painter.rect_stroke(canvas.shrink(1.0), CornerRadius::ZERO, Stroke::new(2.0, fill), egui::StrokeKind::Inside);
let text = format!("Following {name} · {} to stop", crate::toolbar::pretty_shortcut("Escape"));
let galley = painter.layout_no_wrap(text, semibold(11.0), text_on(fill));
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.min.y + 2.0 + (galley.size().y + 6.0) / 2.0), galley.size() + vec2(16.0, 6.0));
painter.rect_filled(r, CornerRadius { nw: 0, ne: 0, sw: 6, se: 6 }, fill);
painter.galley(r.min + vec2(8.0, 3.0), galley, text_on(fill));
}
/// You and the others at the top of the right panel, within `area`: click someone to follow
/// them (again to stop); the chevron lists everyone.
pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
let t = Tokens::get(ui.ctx());
const R: f32 = 12.0;
const STEP: f32 = 20.0;
let cy = area.center().y;
let live = &app.session.live;
let me_name = match live.info.as_ref() {
Some(i) if !i.name.is_empty() => i.name.clone(),
_ => app.session.prefs.user_name.clone(),
};
let me_fill = live.info.as_ref().map_or(t.success, |i| color(&i.color));
let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect();
let follow = live.follow;
// Room for the avatars after yours and the chevron.
let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1);
let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) };
let mut x = area.min.x + R + 2.0;
let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel));
avatar(ui.painter(), pos2(x, cy), R, &me_name, me_fill);
let mut clicked = None;
for (id, name, fill) in peers.iter().take(show) {
x += STEP;
let c = pos2(x, cy);
let following = follow == Some(*id);
let tip = if following { format!("Stop following {name}") } else { format!("Follow {name}") };
let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip);
ring(ui, c);
avatar(ui.painter(), c, R, name, *fill);
if following || resp.hovered() {
ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, *fill));
}
if resp.clicked() {
clicked = Some(*id);
}
}
if more > 0 {
x += STEP;
let c = pos2(x, cy);
ring(ui, c);
ui.painter().circle_filled(c, R, t.pressed);
ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text);
}
let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0));
let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click());
a11y::menu_button(&cresp, "People in this file", a11y::menu_open(&cresp));
if cresp.hovered() {
ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
menu_popup(&cresp).width(220.0).show(|ui| {
let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| {
menu_item_with(ui, label, if check { "Following" } else { "" }, 22.0, |p, r, _| {
avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill)
})
};
row(ui, &format!("{me_name} (you)"), me_fill, false);
for (id, name, fill) in &peers {
if row(ui, name, *fill, follow == Some(*id)) {
clicked = Some(*id);
ui.close();
}
}
if peers.is_empty() {
widgets::label(ui, if app.session.live.status.active() { "Nobody else is here yet" } else { "Not in a live session" }, t.text_secondary);
}
});
if let Some(id) = clicked {
let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) };
app.run("collab.follow", params);
}
}
/// The Share button's popover: the live session (start, invite link, stop or leave) and export.
pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing.y = 6.0;
ui.add_space(4.0);
ui.label(egui::RichText::new("Live session").font(semibold(12.0)));
let status = app.session.live.status.clone();
let role = app.session.live.role();
let (line, col) = match (&status, role) {
(Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(app.session.live.peers.len())), t.success),
(Status::Live, _) => {
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
(if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success)
}
(Status::Failed { .. }, _) => (status.label(), t.danger),
_ => (status.label(), t.text_secondary),
};
widgets::label(ui, &line, col);
match (&status, role) {
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
_ => {
if let Some(i) = app.session.live.info.as_ref().filter(|i| !i.pages.is_empty()) {
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary);
}
widgets::label(ui, UNDO_NOTE, t.text_secondary);
if widgets::button(ui, "Leave session", false, true).clicked() {
app.run("collab.leave", json!({}));
}
}
}
ui.separator();
if menu_item(ui, "Export…", "") {
app.ui.export_preview = true;
ui.close();
}
});
}
fn people_count(others: usize) -> String {
match others {
0 => "nobody else here yet".into(),
1 => "1 person joined".into(),
n => format!("{n} people joined"),
}
}
/// Not in a session: your name, where to share, start; or join one.
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
ui.horizontal(|ui| {
widgets::label(ui, "Your name", t.text);
let name = app.session.prefs.user_name.clone();
if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, "Your name") {
app.run("pref.userName", json!({"name": n}));
}
});
if widgets::checkbox(ui, app.ui.live.lan, "Share on the local network") {
app.ui.live.lan = !app.ui.live.lan;
}
let reach = if app.ui.live.lan {
"Anyone on your network who has the invite link can join."
} else {
"Only this computer can join (turn on network sharing to invite others)."
};
widgets::label(ui, reach, t.text_secondary);
ui.horizontal(|ui| {
if widgets::button(ui, "Start live session", true, true).clicked() {
let bind = if app.ui.live.lan { "lan" } else { "local" };
app.run("collab.host", json!({"bind": bind}));
}
if widgets::button(ui, "Join a session…", false, true).clicked() {
app.ui.live.join = Some(String::new());
ui.close();
}
});
}
/// Hosting: the invite link and URL to copy, and stop.
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
let Some(info) = app.session.live.info.clone() else { return };
for (label, value, what) in [("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")] {
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
let w = ui.available_width() - 64.0;
ui.vertical(|ui| {
ui.set_width(w);
widgets::label(ui, label, t.text_secondary);
ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace());
});
if widgets::button(ui, "Copy", false, true).on_hover_text(format!("Copy the {}", label.to_lowercase())).clicked() {
app.services.clipboard_set_text(value);
app.toast(what);
}
});
}
let t = Tokens::get(ui.ctx());
widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary);
widgets::label(ui, UNDO_NOTE, t.text_secondary);
if widgets::button(ui, "Stop session", false, true).clicked() {
app.run("collab.stop", json!({}));
}
}
/// File ▸ Join live session: paste an invite link.
pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut link) = app.ui.live.join.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut cancel = false;
let mut go = false;
crate::dialogs::window("Join live session", ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
widgets::label(ui, "Paste the invite link the host shared with you.", t.text);
let resp = ui
.add(egui::TextEdit::singleline(&mut link).hint_text("figcraft://join?host=…").desired_width(380.0).id(egui::Id::new("live_join_link")));
a11y::text(&resp, "Invite link", "");
go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
widgets::label(ui, "You'll get the host's file as a new tab and edit it together.", t.text_secondary);
ui.add_space(4.0);
ui.horizontal(|ui| {
go |= widgets::button(ui, "Join", true, !link.trim().is_empty()).clicked();
cancel = widgets::button(ui, "Cancel", false, true).clicked();
});
});
if go && !link.trim().is_empty() && app.run("collab.join", json!({"link": link.trim()})).is_some() {
return;
}
if open && !cancel {
app.ui.live.join = Some(link);
}
}
/// Live session state for `ui.inspect`.
pub fn inspect(app: &FigApp) -> serde_json::Value {
let l = &app.session.live;
json!({
"status": l.status.id(),
"role": l.role(),
"peers": l.peers.len(),
"follow": l.follow,
"cursorsShown": app.ui.live.shown(),
"joinDialog": app.ui.live.join.is_some(),
})
}
/// Peer ids drawn on the canvas, by what they point at (tests).
#[cfg(test)]
pub(crate) fn shown_at(app: &FigApp) -> Vec<(u32, (f64, f64))> {
app.ui.live.shown.iter().map(|(k, v)| (*k, *v)).collect()
}

View File

@ -0,0 +1,331 @@
//! Live sessions drawn headless: a remote cursor with its name tag gliding to where it was
//! reported, a remote selection outlined in its owner's colour, avatars at the top of the right
//! panel (click one to follow), the follow frame, the Share popover and the Join dialog, and no
//! frames while nothing moves.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use egui::{Color32, Event, PointerButton, Pos2, RawInput, Rect, Shape, ViewportId, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Peer, Presence, Role, SessionInfo, Status};
use serde_json::json;
use crate::present_tests::id;
use crate::{FigApp, NoServices};
const HANA: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF);
/// A link that hands out scripted events.
struct Mock(Arc<Mutex<Vec<LiveEvent>>>);
impl LiveLink for Mock {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn leave(&mut self) {}
fn presence(&mut self, _: &Presence) {}
fn send(&mut self, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<LiveEvent> {
std::mem::take(&mut self.0.lock().unwrap())
}
}
struct Harness {
ctx: egui::Context,
app: FigApp,
time: f64,
events: Arc<Mutex<Vec<LiveEvent>>>,
}
/// Everything painted, flattened.
fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec<Shape> {
fn walk(s: &Shape, out: &mut Vec<Shape>) {
match s {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
other => out.push(other.clone()),
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
impl Harness {
fn new() -> (Self, NodeId) {
let mut s = Session::new();
let rect = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
s.execute("select.set", &json!({"ids": []})).unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
s.live.install(Box::new(Mock(events.clone())));
let h = Harness { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0, events };
(h, rect)
}
/// One frame; the shapes it painted and the repaint delay it asked for.
fn frame(&mut self, events: Vec<Event>) -> (Vec<Shape>, Duration) {
self.time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
let delay = out.viewport_output.get(&ViewportId::ROOT).map_or(Duration::MAX, |v| v.repaint_delay);
(flat(&out.shapes), delay)
}
fn frames(&mut self, n: usize) -> Vec<Shape> {
let mut last = Vec::new();
for _ in 0..n {
last = self.frame(vec![]).0;
}
last
}
fn click(&mut self, p: Pos2) {
for pressed in [true, false] {
self.frame(vec![
Event::PointerMoved(p),
Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() },
]);
}
}
fn send(&mut self, e: Vec<LiveEvent>) {
self.events.lock().unwrap().extend(e);
}
/// Joined as Ada, with Hana (the host) already here.
fn joined(&mut self) {
self.app.session.live.status = Status::Connecting;
let info = SessionInfo {
role: Role::Guest,
me: 2,
name: "Ada Lovelace".into(),
color: "#14AE5C".into(),
file_name: "Shared".into(),
pages: vec![],
link: String::new(),
url: String::new(),
address: "127.0.0.1:47900".into(),
network: false,
};
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
}
/// The host's welcome with the document (a session server seeded with this document): it opens
/// as a new tab, which is then the synced document.
fn sync(&mut self) {
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
let mut server = Server::new(self.app.session.doc().unwrap().clone(), "", Limits::default());
let ClientMsg::Hello(host) = Client::hello("f", "", "Hana", "") else { panic!("no hello") };
server.join(host).unwrap();
let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
let (_, outs) = server.join(me).unwrap();
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
self.send(vec![LiveEvent::Sync(w)]);
self.frames(2);
assert!(self.app.session.live_shared());
}
fn page(&self) -> NodeId {
self.app.session.page().unwrap()
}
}
fn texts(shapes: &[Shape]) -> Vec<(String, Pos2, egui::Vec2)> {
shapes
.iter()
.filter_map(|s| match s {
Shape::Text(t) => Some((t.galley.text().to_string(), t.pos, t.galley.size())),
_ => None,
})
.collect()
}
fn has_text(shapes: &[Shape], s: &str) -> bool {
texts(shapes).iter().any(|(t, _, _)| t == s)
}
/// The middle of the text `s` on screen.
fn text_center(shapes: &[Shape], s: &str) -> Pos2 {
let (_, p, size) = texts(shapes).into_iter().find(|(t, _, _)| t == s).unwrap_or_else(|| panic!("no text {s:?}"));
p + size / 2.0
}
/// Filled polygons and outlines in `c`.
fn filled(shapes: &[Shape], c: Color32) -> usize {
shapes.iter().filter(|s| matches!(s, Shape::Path(p) if p.fill == c)).count()
}
fn outlined(shapes: &[Shape], c: Color32) -> usize {
shapes
.iter()
.filter(|s| matches!(s, Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && p.stroke.color == egui::epaint::ColorMode::Solid(c)))
.count()
}
fn cursor(page: NodeId, x: f64, y: f64) -> Presence {
Presence { cursor: Some((page, x, y)), ..Presence::default() }
}
#[test]
fn remote_cursors_selections_and_avatars_draw() {
let (mut h, rect) = Harness::new();
h.frames(3);
h.joined();
let page = h.page();
h.send(vec![LiveEvent::Presence(
1,
Presence { selection: Some(vec![rect]), viewport: Some((page, 0.0, 0.0, 600.0, 400.0)), ..cursor(page, 150.0, 150.0) },
)]);
// Before the document arrives, the layer ids in her selection aren't this document's.
let shapes = h.frames(3);
assert_eq!(outlined(&shapes, HANA), 0, "no outline before the document syncs");
h.sync();
assert_eq!(h.app.session.docs.len(), 2, "the host's file opened as a tab");
let shapes = h.frames(2);
assert_eq!(h.app.session.live.status, Status::Live);
// The cursor: the dart in Hana's colour, her name beside it, at her page position.
assert!(has_text(&shapes, "Hana"), "the name tag");
assert_eq!(filled(&shapes, HANA), 2, "the pointer's two halves");
assert_eq!(crate::live::shown_at(&h.app), vec![(1, (150.0, 150.0))]);
// Her selection, outlined in her colour (the pointer's white edge aside).
assert_eq!(outlined(&shapes, HANA), 1, "the selected rectangle");
// Avatars: you and Hana; the peers popover lists both.
assert!(has_text(&shapes, "AL") && has_text(&shapes, "H"));
assert_eq!(h.app.inspect()["live"]["cursorsShown"], json!([1]));
}
#[test]
fn a_cursor_glides_then_rests() {
let (mut h, _) = Harness::new();
h.frames(3);
h.joined();
let page = h.page();
h.send(vec![LiveEvent::Presence(1, cursor(page, 0.0, 0.0))]);
h.frames(2);
h.send(vec![LiveEvent::Presence(1, cursor(page, 400.0, 0.0))]);
let (_, delay) = h.frame(vec![]);
let x = crate::live::shown_at(&h.app)[0].1.0;
assert!(x > 0.0 && x < 400.0, "on its way: {x}");
assert!(delay < Duration::from_millis(50), "gliding asks for frames");
h.frames(60);
assert_eq!(crate::live::shown_at(&h.app)[0].1, (400.0, 0.0), "arrived");
// Settled: no more frames until something happens.
let mut quiet = 0;
for _ in 0..200 {
let rendering = h.app.worker.as_ref().is_some_and(|w| w.in_flight);
if rendering {
std::thread::sleep(Duration::from_millis(2));
}
let (_, d) = h.frame(vec![]);
quiet = if d >= Duration::from_secs(1) && !rendering { quiet + 1 } else { 0 };
if quiet >= 3 {
break;
}
}
assert!(quiet >= 3, "a resting cursor draws no frames: {:?}", h.ctx.repaint_causes());
// Reduced motion: it jumps.
h.app.session.prefs.reduce_motion = true;
h.send(vec![LiveEvent::Presence(1, cursor(page, -50.0, 20.0))]);
h.frame(vec![]);
assert_eq!(crate::live::shown_at(&h.app)[0].1, (-50.0, 20.0));
}
#[test]
fn cursors_follow_pages_in_a_shared_document() {
let (mut h, rect) = Harness::new();
h.frames(3);
h.joined();
let other = id(&h.app.session.execute("page.add", &json!({"name": "Two"})).unwrap());
h.app.session.execute("page.select", &json!({"index": 0})).unwrap();
h.sync();
h.app.session.execute("page.select", &json!({"index": 0})).unwrap();
h.send(vec![LiveEvent::Presence(
1,
Presence { selection: Some(vec![rect]), viewport: Some((other, 0.0, 0.0, 10.0, 10.0)), ..cursor(other, 1.0, 1.0) },
)]);
let shapes = h.frames(2);
assert!(crate::live::shown_at(&h.app).is_empty(), "on another page of this file");
assert_eq!(outlined(&shapes, HANA), 0, "a selection on another page isn't outlined here");
// With another document in front, page ids mean nothing there: the cursor shows anyway.
h.app.session.execute("doc.switch", &json!({"index": 0})).unwrap();
assert!(!h.app.session.live_shared());
h.send(vec![LiveEvent::Presence(1, cursor(NodeId(99_999), 5.0, 5.0))]);
h.frames(2);
assert_eq!(crate::live::shown_at(&h.app), vec![(1, (5.0, 5.0))]);
// Gone when they leave.
h.send(vec![LiveEvent::PeerLeft(1)]);
let shapes = h.frames(2);
assert!(crate::live::shown_at(&h.app).is_empty());
assert!(!has_text(&shapes, "Hana"));
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left"));
}
#[test]
fn clicking_an_avatar_follows_and_escape_stops() {
let (mut h, _) = Harness::new();
h.frames(3);
h.joined();
let page = h.page();
h.send(vec![LiveEvent::Presence(1, Presence { viewport: Some((page, 1000.0, 1000.0, 600.0, 400.0)), ..Presence::default() })]);
let shapes = h.frames(2);
let at = text_center(&shapes, "H");
h.click(at);
assert_eq!(h.app.session.live.follow, Some(1));
let shapes = h.frames(2);
let view = h.app.session.ds().unwrap().view.clone();
assert_eq!((view.cx, view.cy), (1300.0, 1200.0), "her view");
assert!(texts(&shapes).iter().any(|(t, _, _)| t.starts_with("Following Hana")), "the follow banner");
// Escape on the canvas stops following.
h.click(pos2(700.0, 500.0));
h.app.session.execute("collab.follow", &json!({"client": 1})).unwrap();
for pressed in [true, false] {
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
}
assert_eq!(h.app.session.live.follow, None);
// Scrolling the canvas stops it too.
h.app.session.execute("collab.follow", &json!({"client": 1})).unwrap();
h.frame(vec![Event::PointerMoved(pos2(700.0, 500.0))]);
h.frame(vec![Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -40.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
}]);
h.frames(3);
assert_eq!(h.app.session.live.follow, None);
}
#[test]
fn share_popover_and_join_dialog() {
let (mut h, _) = Harness::new();
let shapes = h.frames(3);
let share = text_center(&shapes, "Share");
h.click(share);
let shapes = h.frames(2);
for t in ["Live session", "Start live session", "Share on the local network", "Export…"] {
assert!(has_text(&shapes, t), "the popover shows {t:?}");
}
// Starting fails with this link: the error shows, nothing else changes.
h.click(text_center(&shapes, "Start live session"));
assert_eq!(h.app.session.live.status, Status::Off);
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not in this test")));
// File ▸ Join live session opens the dialog.
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
crate::menus::invoke(&mut h.app, "collab.join");
let shapes = h.frames(2);
assert!(has_text(&shapes, "Join live session") && has_text(&shapes, "Paste the invite link the host shared with you."));
assert_eq!(h.app.inspect()["live"]["joinDialog"], true);
h.click(text_center(&shapes, "Cancel"));
h.frames(1);
assert!(h.app.ui.live.join.is_none());
}

View File

@ -126,6 +126,8 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0)));
}
"edit.findReplace" => app.ui.find = Some((String::new(), String::new())),
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
"collab.join" => app.ui.live.join = Some(String::new()),
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// SVG for other apps, taken before a cut removes the layers.

View File

@ -59,18 +59,13 @@ impl FigApp {
pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
// Top bar: avatar, present, share.
// Top bar: people (you and the others in a live session), present, share.
let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
ui.painter().circle_filled(pos2(top.min.x + 26.0, top.center().y), 13.0, Color32::from_rgb(0x14, 0xAE, 0x5C));
ui.painter().text(pos2(top.min.x + 26.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(12.0), Color32::WHITE);
icons::paint(ui.painter(), Rect::from_center_size(pos2(top.min.x + 48.0, top.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
let share = Rect::from_min_size(pos2(top.max.x - 70.0, top.center().y - 16.0), vec2(56.0, 32.0));
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip("Export and share");
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip("Share: live session, export");
ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT });
ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.5), Color32::WHITE);
if sresp.clicked() {
app.ui.export_preview = true;
}
crate::live::share_popover(app, &sresp);
// Play presents; its chevron opens the play menu (present, or preview in a pane).
let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0));
let (tri_r, chev_r) =
@ -85,6 +80,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let c = tri.center();
ui.painter().add(egui::Shape::closed_line(vec![c + vec2(-5.0, -7.0), c + vec2(7.0, 0.0), c + vec2(-5.0, 7.0)], Stroke::new(1.4, t.icon)));
icons::paint(ui.painter(), Rect::from_center_size(play.center() + vec2(12.0, 0.0), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
crate::live::people_bar(app, ui, Rect::from_min_max(pos2(top.min.x + 12.0, top.min.y), pos2(play.min.x - 6.0, top.max.y)));
if presp.clicked() {
app.run("proto.present", json!({}));
}

View File

@ -29,6 +29,7 @@ replaced.
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
| L6 | `figcraft-collab-net` | ~2.4k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the session host the app embeds (runs the collaboration core's server), invite links, presence relay and limits; the only crate allowed networking dependencies |
| apps | `figcraft`, `figcraft-cli`, `figcraft-web` | ~1.9k | Desktop app (eframe + wgpu), command line, browser build (WASM, WebGPU/WebGL2) |
| plugins | `plugins/` (own workspace) | | Rust plugin SDK and two example plugins, built for wasm32 by `cargo xtask plugins` ([`plugins.md`](plugins.md)) |
| tools | `xtask` | | `ci`, `layers`, `assets`, `parity`, `version`, `icon`, `wasm`, `plugins`, `bench` |

View File

@ -2,7 +2,10 @@
Status: milestone M9. **Step 1 (the core, no networking) is implemented** in `crates/collab`:
identifiers, the object/property model, change sets, ordering and validation, client
reconciliation, derived data and the message encoding. The rest is the plan the next
reconciliation, derived data and the message encoding. **Step 2 is implemented** in
`crates/collab-net` and the engine: the WebSocket transport, the session host the desktop app
embeds, invite links, document sync end to end (several apps edit one file), live cursors,
selections, avatars and follow. Undo is not per person yet (step 3). The rest is the plan the next
implementation PRs follow and update; sections say what is built.
Goal: several people edit the same `.figcraft` file at the same time and see each other's edits,
@ -223,11 +226,30 @@ never enter your undo history.
## Presence
*Built* (`figcraft_engine::live`, `crates/collab-net`, `crates/ui-egui/src/live.rs`).
- Cursor position in page coordinates plus the page id; selection ids; viewport rectangle; whether
the user is typing in a text layer (to show their caret and selection inside it).
- Shown as named, coloured cursors on the canvas, selection outlines in the user's colour, avatars
in the title bar; click an avatar to **follow** that user's viewport (Escape or any pan stops).
- Never stored; dropped when a client disconnects; the server rate-limits and coalesces.
the user is typing in a text layer (to show their caret and selection inside it). The message is
the core's `Presence`; the engine builds yours from the active document (page, selection, view,
edited text) and the pointer over the canvas, and sends it only when it changed.
- Shown as named, coloured cursors on the canvas (a pointer drawn in code with a name tag in the
person's colour, gliding to each reported position; reduced motion makes it jump), selection
outlines in the person's colour, and avatars (initials) at the top of the right panel beside
Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click
again, press Escape or pan/zoom yourself to stop. The chevron lists everyone.
- Never stored; dropped when a client disconnects. Throttled to about 30 updates a second on both
ends, latest wins (a value offered too soon waits and is replaced by newer ones, so the last
position always arrives). A newcomer gets everyone's latest presence after its welcome.
- Presence lines up with your canvas while the session's document is in front
(`Session::live_shared`): cursors on your page, selections outlined, following switches pages.
With another document in front (or before the welcome arrives), cursors show in page
coordinates on whatever page you look at and nothing is outlined.
- Colours and names: the host hands out the first free colour of an eight-colour palette and
"Guest N" to people without a name (Preferences: your name, `pref.userName`; also in the Share
popover). After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it
learns its name and colour.
- Idle cost is zero: network threads block, wake the window (`request_repaint`) only when a
message arrives, and cursors only ask for frames while they glide.
## Hosting and joining
@ -243,6 +265,52 @@ never enter your undo history.
- **Auth**: an unguessable per-session token in the invite link (first step); accounts and per-file
permissions (view, edit) come with the standalone server.
*Built* (hosting from the app and joining):
- "Share" in the right panel opens a popover: your name, **Share on the local network** (off:
127.0.0.1 only), **Start live session**; while hosting, the invite link and the `ws://` URL with
Copy buttons, how many joined, and **Stop session**; while joined, **Leave session**. "File ▸
Join live session…" (and "Join a session…" in the popover) asks for the link. Commands:
`collab.host {port?, bind?: "local" | "lan" | <ip>}`, `collab.stop`, `collab.join {link}`,
`collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's
pointer).
- The host listens on port 47900 (any free port when it's taken, or `port`), on 127.0.0.1 unless
the user chooses the local network (`lan`: the address of the interface the default route uses)
or names an interface (`bind: "<ip>"`, e.g. a VPN's). It runs the core's `Server` as the
authority and takes part itself without a socket.
- Links: `figcraft://join?host=<ip:port>&file=<id>&token=<secret>` or
`ws://<ip:port>/?file=<id>&token=<secret>` (either works for joining). The client connects to
`ws://<ip:port>/` and sends the token only inside its `Hello`. No `figcraft://` handler is
registered with the operating system yet: links are pasted into the Join dialog.
- Reconnect: a connection that drops after it was live reconnects with growing pauses (0.5 s,
doubling up to 10 s, 12 attempts); a refusal (wrong token, full) or the host ending the session
is final; a first join that can't reach the host fails at once. A reconnect is a new client id
(resuming with the last confirmed `seq` is step 3).
- The browser build has the commands but says live sessions need the desktop app (its WebSocket
client is step 4).
*Built* (document sync between apps):
- The host's session runs the core's `Server`, seeded with the document it shared; the host's
window is a `Client` of it like everyone else (it joins through the hub, without a socket), so
its own edits go through the same ordering. A guest's welcome carries the whole document
(pages, layers, styles, variables, images): it opens as a new tab, the guest's own files stay
open, and leaving keeps it as a local copy.
- The engine syncs one open document per session (`Live::doc_uid`). After every local action it
diffs that document against the client's view into one batch (`ClientMsg::Changes`); a gesture
(a drag, a resize) is sent once, when it ends, and nothing is sent while a version or a live
text preview shows. Batches the server orders (`Changes`, `Reject`) go through
`Client::receive` with the engine's recompute hook, and the resulting document is installed with
`Session::apply_remote`, which keeps your selection, page and view and never adds an undo step.
Batches that arrive during a gesture wait for it to end. New layers take ids in the client's
space (`client_id << 32 | counter`). Closing the session's document leaves the session.
- Each applied batch wakes the window (the inbox calls `request_repaint`); nothing runs while
nobody edits or moves.
- Limits of this step: undo restores whole earlier states, so it can also revert others' recent
edits until per-user undo (step 3), and the Share popover says so; a reconnect is a new client
whose welcome replaces the document, so edits made while disconnected are lost (resuming from
the last confirmed `seq` with queued batches is step 3).
## Offline and reconnect
On a dropped connection the client keeps editing locally, queues batches, and on reconnect sends
@ -255,6 +323,12 @@ Everything stays a command: `collab.host`, `collab.join`, `collab.leave`, `colla
`collab.follow` are ordinary commands, so the CLI, the control channel and MCP can host and join
sessions; an agent can be a participant with its own cursor.
*Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli
mcp`) both have the network link installed; `collab.join` returns at once with status
`"connecting"` and `collab.peers` tells when it is live (or why it failed). An agent's pointer is
`collab.cursor {x, y}`. The one-shot CLI (`figcraft-cli run`) has no link: there the commands say
live sessions aren't available.
## Security
The server validates every change (types, ranges, the same caps the engine applies to commands,
@ -262,6 +336,32 @@ tree rules, message size limits) and never trusts client-computed data. The serv
parsers are fuzzed like the file importers. Sessions bind to all interfaces only when the user
chooses to share on the network; the default is the invite link with a random token.
*Built* (transport and host), and the threat model it assumes:
- **Who can connect.** By default the host listens on 127.0.0.1: only programs on the same
machine can reach it. With "Share on the local network" it listens on the LAN interface's address
(not on all interfaces), and anyone who can reach that address and port can open a connection;
joining then needs the token. Untrusted networks (public Wi-Fi) are where not to turn it on; the
popover says who can join.
- **The token.** 128 bits from the operating system's random number generator, URL-safe base64,
new for every session (stopping and starting again invalidates old links). It is compared in
constant time (the core's `Server`). It is in the invite link by design, so whoever holds the link
can join until the session stops: share it like a password. It never travels in the WebSocket
request (the path is `/`), so proxies don't log it; FigCraft never logs it (logs name addresses
and ports only), and errors about links never echo the link. Traffic is not encrypted (`ws://`):
on a shared network, others on the path could read presence and the token. TLS (`wss://`) comes
with the standalone server behind a reverse proxy; until then, use a trusted LAN or a VPN.
- **Limits.** At most 64 open sockets (joined or not) and the core's 256 people; a connection must
finish the WebSocket handshake and say hello within 10 s, and one silent for 45 s is dropped
(clients ping every 15 s); client messages are at most 16 MiB (what the host sends keeps the
core's 64 MiB cap: a welcome carries the whole document); a client sending more than 100
messages a second on average (bursts of 400) is disconnected, and so is one whose 1,024-message
outgoing queue fills (it can't keep up); names are cut to 64 characters without control
characters; presence numbers must be finite and selections are cut to 10,000 ids.
- **Malformed input.** Anything that isn't a binary message of the current wire version that
decodes in full is answered with `Refused` and a close; the first message must be `Hello`, for
this file. The invite parser and the host's reader are fuzzed (`crates/collab-net`).
## Implementation plan
1. **Core (no networking):** client-scoped ids, the object/property model over `Document`, diff
@ -272,6 +372,9 @@ chooses to share on the network; the default is the invite link with a random to
**Done.**
2. **Transport + embedded host:** WebSocket server/client (native), the binary encoding, the
`collab.*` commands, Share / Join UI, live cursors, selections, avatars and follow.
**Done** (`crates/collab-net`, layer 6 beside the UI: blocking `tungstenite` with a reader and
a writer thread per connection, no async runtime; the engine holds the live state and the sync
client and reaches the network only through the `LiveLink` the app installs).
3. **Per-user undo**, reconnect and offline queueing, rejection handling, presence for text
editing.
4. **Browser client** (WebSocket through `web-sys`).

View File

@ -10,7 +10,7 @@ nobody can co-edit yet ([`collab-protocol.md`](collab-protocol.md)). Remaining:
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Left: WebSocket transport, hosting and joining, per-user undo, reconnect, browser client, standalone server | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Left: per-user undo, resuming after a reconnect, browser client, standalone server | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | no (presence messages encoded; no UI or transport) | 8–15 |
| Cursor chat, audio, high-fives | yes | no | 4–8 |
| Share links, permissions (view / edit), teams and projects | yes | no | 8–17 |

View File

@ -88,7 +88,11 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
`canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last
pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
the system's accessibility setting turns the interface's animations off.
the system's accessibility setting turns the interface's animations off. `live` is the live
session as the window shows it: `status`, `role`, the number of `peers`, who you `follow`, the
peers whose cursors are drawn on the canvas (`cursorsShown`) and whether the Join dialog is open
(`joinDialog`); the session itself is `engine.execute {command: "collab.peers"}` (hosting and
joining are `collab.host` and `collab.join`, see [mcp.md](mcp.md#live-sessions)).
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`

View File

@ -59,12 +59,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
audio, share links and permissions. Comments and versions are local to the file.
- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server
ordering and validation, client reconciliation, binary messages, randomized convergence tests over
real commands), but there is no networking yet, so nobody can co-edit (`docs/collab-protocol.md`).
real commands), and live sessions run over the network (`crates/collab-net`: hosting from the
app, invite links, document sync between apps, live cursors, selections, avatars and follow),
but undo isn't per person, a reconnect loses offline edits, the browser can't join, and there
are no share links or permissions (`docs/collab-protocol.md`).
- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work
without it.
- **Estimate:** 45–90 h: WebSocket transport and a session server hosted from the app (per the
protocol), presence, then sharing and permissions; the browser build needs the
same client.
- **Estimate:** 45–90 h: per-user undo, resuming after a reconnect, then sharing and
permissions and a standalone server; the browser build needs its own WebSocket client.
### 3. Nothing checked against real Figma files
- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and

View File

@ -484,6 +484,30 @@ save)}` returns `{changes, count, bytes}`: the change batch a live session would
(per-property `create` / `set` / `delete` and document-level changes, see
`docs/collab-protocol.md`) and its binary size. It changes nothing.
### Live sessions
People (and agents) edit one file together and see each other's cursors, selections and
viewports. Both the headless session and the attached app can host and join; each call takes in
what happened since the last one and sends your edits (a joined file opens as a new document).
- `collab.host {port?, bind?}` starts hosting the active document: `bind` is `"local"` (this
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
or any free one when it's taken (`port` picks one, 0 any). Returns the session as
`collab.peers` does, with `link` (`figcraft://join?host=…&file=…&token=…`) and `url`
(`ws://…/?file=…&token=…`): both carry the session's secret, so share them only with people you
invite. `collab.stop {}` ends it.
- `collab.join {link}` (either form) returns at once with `status: "connecting"`; poll
`collab.peers` until `status` is `"live"`, or `"failed"` with a `reason` (a wrong token, a full
session, an unreachable host). `collab.leave {}` leaves.
- `collab.peers {}` → `{available, status (off|connecting|live|reconnecting|failed), reason?,
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
follow, syncing (the document arrived), document (the index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection: [ids], viewport:
[page, x, y, w, h], editing_text}}]}`.
- `collab.cursor {x, y}` puts your cursor at a page position (omit both to hide it): an agent's
pointer. Your selection, page and view go out by themselves when they change.
- `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any
pan or zoom of your own, or Escape).
### Plugins
Sandboxed WebAssembly plugins act through the same commands (see [`plugins.md`](plugins.md) for the

View File

@ -17,7 +17,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
40–60 h, parallel by tool.
4. **Localization layer**, then Figma's own languages first (ja, ko, es, pt-BR, fr, de): 15–25 h
for the layer, 3–5 h per language plus native review.
5. **Multiplayer M9** networking on the landed core, per [`collab-protocol.md`](collab-protocol.md): 45–90 h.
5. **Multiplayer M9**: per-user undo, reconnect with offline edits, the browser client, a standalone server and sharing on top of the live sessions (the WebSocket transport, hosting from the app, invite links, document sync, live cursors, selections, avatars and follow landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h.
## Alpha gate
@ -55,7 +55,7 @@ blockers.
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence) | WebSocket transport, hosting and joining, cursors, per-user undo, reconnect, browser client, standalone server, sharing | 45–90 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands) | per-user undo, resuming after a reconnect, `figcraft://` link handler, browser client, standalone server, sharing | 45–90 |
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |

View File

@ -21,8 +21,13 @@ const TABLE: &[(&str, u8)] = &[
("engine", 5),
("ui-egui", 6),
("mcp", 6),
// Live sessions over the network (WebSockets): above the engine, beside the UI.
("collab-net", 6),
];
/// Networking crates: only L6 (`collab-net`) may use them.
const NETWORK: &[&str] = &["tungstenite", "tokio", "tokio-tungstenite", "mio", "socket2"];
pub fn check(root: &std::path::Path) -> bool {
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["metadata", "--format-version", "1", "--no-deps"])
@ -44,6 +49,10 @@ pub fn check(root: &std::path::Path) -> bool {
eprintln!("layers: {name} (L{l}) depends on UI crate {dn}");
ok = false;
}
if l < 6 && NETWORK.contains(&dn) {
eprintln!("layers: {name} (L{l}) depends on networking crate {dn}");
ok = false;
}
if let Some(&dl) = layer.get(dn)
&& dl >= l
&& !(dl == l && same_layer_ok(&name, dn))
@ -60,6 +69,7 @@ pub fn check(root: &std::path::Path) -> bool {
}
fn same_layer_ok(from: &str, to: &str) -> bool {
// Within L0: color uses geom types; within L2: text/layout/pathops/collab are independent.
matches!((from, to), ("figcraft-color", "figcraft-geom") | ("figcraft-render", "figcraft-format"))
// Within L0: color uses geom types; within L2: text/layout/pathops/collab are independent;
// within L6 the MCP server joins live sessions through collab-net.
matches!((from, to), ("figcraft-color", "figcraft-geom") | ("figcraft-render", "figcraft-format") | ("figcraft-mcp", "figcraft-collab-net"))
}

View File

@ -69,6 +69,8 @@ fn wasm() -> bool {
"-p",
"figcraft-collab",
"-p",
"figcraft-collab-net",
"-p",
"figcraft-engine",
"-p",
"figcraft-ui-egui",