From 57cf1bec48481b241f92f7f3f32415998f79b3ae Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 29 Oct 2026 10:23:58 +0800 Subject: [PATCH] fix(collab): keep in-use images and resync clients after filtered history (#1086) - An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would swap the picture that layer shows, which giving the key other bytes directly can't. A batch made by the diff stops using an image (node changes, then styles) before it removes it; an undo keeps the changes that apply, so its image may stay. - Restore and replay leave out the image entries history written under older rules can't apply, using one method shared with the undo history (Replica::apply_kept, extracted from history.rs). It is exact for every image rule, including removals in use and replacements, and leaves out only image entries: any other change that doesn't apply is still an error. The image-only filter it replaces is gone. - A server whose restore or replay left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot, including one at the same sequence number. - Tests: an_image_in_use_cannot_be_removed, an_image_no_longer_in_use_can_be_removed, undoing_an_image_layer_still_works, a_restart_replays_past_a_logged_removal_of_an_image_in_use, a_resume_after_history_was_left_out_catches_up_with_the_snapshot, a_resume_after_a_clean_restart_catches_up_with_the_log, a_snapshot_with_a_broken_layer_change_still_fails_to_open. - Docs: collab-protocol.md, collaboration-parity.md. --- crates/collab/src/change.rs | 7 ++ crates/collab/src/docprops.rs | 33 ++------- crates/collab/src/history.rs | 12 +--- crates/collab/src/image_tests.rs | 43 ++++++++++++ crates/collab/src/journal_tests.rs | 103 +++++++++++++++++++++++++++++ crates/collab/src/replica.rs | 42 ++++++++++-- crates/collab/src/server.rs | 33 ++++++--- docs/collab-protocol.md | 11 +-- docs/collaboration-parity.md | 4 +- 9 files changed, 226 insertions(+), 62 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 4ae0015b9..dedae208a 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -313,6 +313,13 @@ impl DocProp { } } +impl Change { + /// Whether this puts or removes an image entry. + pub(crate) fn is_image(&self) -> bool { + matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) }) + } +} + impl Entry { /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index cff373000..f53040fbf 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -176,34 +176,6 @@ fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } -/// `changes` without the image entries applying them to `doc` would refuse ([`image_for`]): an -/// invalid image, or other bytes under a key `doc` or an earlier change of the batch already -/// holds. For history written before those checks (a saved snapshot, a server's log): the rest of -/// it still applies, where refusing the whole would leave a document that can't open or drop -/// every edit after the image. -pub(crate) fn without_refused_images(doc: &Document, changes: &[Change]) -> Vec { - // What each image key holds as the batch goes: the document's image, then the batch's own. - let mut held: BTreeMap<&str, Option> = BTreeMap::new(); - let mut out = Vec::with_capacity(changes.len()); - for c in changes { - match c { - Change::Put { entry: Entry::Image(k), value } => { - let now = held.get(k.as_str()).cloned().unwrap_or_else(|| doc.images.get(k).cloned()); - if let Ok(img) = image_for(now.as_ref(), k, value) { - held.insert(k, Some(img)); - out.push(c.clone()); - } - } - Change::Remove { entry: Entry::Image(k) } => { - held.insert(k, None); - out.push(c.clone()); - } - _ => out.push(c.clone()), - } - } - out -} - /// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that /// already holds an image can't be given other bytes. A key always names the same bytes /// (producers key images by their bytes; the REST importer's placeholder keys always hold the @@ -386,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result doc.dev_snapshots.remove(k); } Entry::Image(k) => { + // An image a layer or style shows stays: removing it would let other bytes go + // under its key, swapping the picture that layer shows ([`image_for`]). + if figcraft_doc::used_images(doc).contains(k) { + return Err(Error::Invalid(format!("image: {k} is still in use"))); + } doc.images.remove(k); } Entry::Ballot(k) => { diff --git a/crates/collab/src/history.rs b/crates/collab/src/history.rs index a45e88a43..50f09c27f 100644 --- a/crates/collab/src/history.rs +++ b/crates/collab/src/history.rs @@ -311,17 +311,7 @@ impl Step { apply_all(&mut work, &mut out, deletes); // Changes that apply one by one can still fail the batch's checks together (rare): keep // only those that pass them in order. - if view.clone().apply(&out, Mode::Strict).is_err() { - let mut w = view.clone(); - out.retain(|c| { - let mut t = w.clone(); - let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok(); - if ok { - w = t; - } - ok - }); - } + view.clone().apply_kept(&mut out, |_| true)?; Ok(out) } } diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs index f9b4d7c73..9588a68c6 100644 --- a/crates/collab/src/image_tests.rs +++ b/crates/collab/src/image_tests.rs @@ -213,3 +213,46 @@ fn a_restart_replays_past_a_logged_image_replacement() { assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out"); assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept"); } + +/// A document whose rectangle shows `img1` (the first bytes), and that rectangle. +fn in_use() -> (Document, figcraft_doc::NodeId) { + let mut doc = Document::new("t"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)]; + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap(); + (doc, id) +} + +/// An image a layer or style still shows can't be removed: removing it and putting other bytes +/// under its key would swap the picture that layer shows, which giving the key other bytes +/// directly can't do (#1086). +#[test] +fn an_image_in_use_cannot_be_removed() { + let (mut doc, _) = in_use(); + let remove = Change::Remove { entry: Entry::Image("img1".into()) }; + assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it"); + assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err()); + assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice())); +} + +/// A batch that first stops using an image, then removes it (the order a diff makes), applies. +#[test] +fn an_image_no_longer_in_use_can_be_removed() { + let (doc, id) = in_use(); + let mut r = crate::Replica::new(doc); + let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }]; + r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image"); + assert!(r.document().images.is_empty()); +} + +/// Only image entries are left out of saved history: a snapshot whose other changes don't apply +/// is still an error, not a document missing them. +#[test] +fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() { + let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap(); + // A property of a layer that isn't there: an error when applied (unlike deleting it). + let missing = figcraft_doc::NodeId(u64::MAX); + snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) }); + assert!(crate::Replica::from_snapshot(&snapshot).is_err()); +} diff --git a/crates/collab/src/journal_tests.rs b/crates/collab/src/journal_tests.rs index 061efc4b4..a8a7c4c25 100644 --- a/crates/collab/src/journal_tests.rs +++ b/crates/collab/src/journal_tests.rs @@ -220,3 +220,106 @@ fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() { let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true); assert_eq!(a.view(), r.replica()); } + +/// `a` adds a rectangle showing image `img1`; returns the rectangle. +fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId { + let page = s.document().first_page().unwrap(); + let mut id = None; + let m = edit(a, |d| { + let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)]; + d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 }); + id = Some(r); + }); + send(s, a.id(), m, &mut [a]); + id.unwrap() +} + +/// Undoing an image's insertion still works with images in use protected from removal (#1086). +#[test] +fn undoing_an_image_layer_still_works() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let id = add_image_layer(&mut s, &mut a); + for m in a.undo(&NoRecompute).unwrap() { + send(&mut s, a.id(), m, &mut [&mut a]); + } + assert!(s.document().node(id).is_none(), "the layer is gone"); + assert_eq!(a.view(), s.replica()); +} + +/// A server's log written before the image checks may remove an image a layer still shows: a +/// restart replays past it, keeping the image and every edit after it (#1086). +#[test] +fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() { + let mut s = Server::new(base(), "", Limits::default()); + s.journaling(true); + let mut a = join_as(&mut s, Access::Edit); + add_image_layer(&mut s, &mut a); + let saved = s.state().unwrap(); + let page = s.document().first_page().unwrap(); + let mut journal = s.take_journal(); + let base_seq = saved.seq; + let next = |n: u64| base_seq + n; + journal.push(Journal::Accepted(message::Accepted { + seq: next(1), + author: a.id(), + batch_id: 90, + changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }], + })); + let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) }; + journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] })); + let mut r = Server::restore(saved, "", Limits::default()).unwrap(); + for j in journal { + r.replay(j).expect("the log replays"); + } + assert_eq!(r.seq(), next(2)); + assert!(r.document().images.contains_key("img1"), "the image a layer shows stays"); + assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept"); +} + +/// What a resuming client is told to catch up with. +fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp { + a.disconnect(); + let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") }; + let (id, out) = r.join(h).unwrap(); + out.into_iter() + .find_map(|o| match o { + Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up), + _ => None, + }) + .expect("a welcome") +} + +/// A restart that left an image out of the saved history (an older server accepted it) catches +/// every resuming client up with the snapshot, even one at the same sequence number: a client +/// that kept the image from before would otherwise go on showing what the session no longer +/// has (#1086). +#[test] +fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let mut state = s.state().unwrap(); + let (key, bytes) = crate::image_tests::forged(); + state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes)); + let mut r = Server::restore(state, "", Limits::default()).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore"); + + // Left out on replay. + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let state = s.state().unwrap(); + let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap(); + let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)]; + r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay"); +} + +/// A restart that left nothing out keeps resuming clients on the cheaper delta. +#[test] +fn a_resume_after_a_clean_restart_catches_up_with_the_log() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_))); +} diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs index a16413ee5..0bf0391af 100644 --- a/crates/collab/src/replica.rs +++ b/crates/collab/src/replica.rs @@ -72,15 +72,47 @@ impl Replica { /// Rebuild a replica from a [`snapshot`](Self::snapshot). pub fn from_snapshot(changes: &[Change]) -> Result { + Self::from_snapshot_leaving_out(changes).map(|(r, _)| r) + } + + /// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a + /// snapshot saved before the image checks may hold one they refuse, and opens without it. + pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> { let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() }; - // A snapshot saved before the image checks may hold an image they refuse: it opens - // without that image (`docprops::without_refused_images`). - let changes = docprops::without_refused_images(&r.doc, changes); - r.apply(&changes, Mode::Strict)?; + let mut changes = changes.to_vec(); + let left_out = r.apply_kept(&mut changes, Change::is_image)?; if r.doc.pages.is_empty() { return Err(Error::Invalid("a snapshot needs a page".into())); } - Ok(r) + Ok((r, left_out)) + } + + /// Apply `changes` strictly. When they don't apply together — history written under older + /// rules, or changes that apply one by one but fail the batch's checks together — apply those + /// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out` + /// allows it (otherwise that change's error). Returns how many were left out. + pub(crate) fn apply_kept(&mut self, changes: &mut Vec, may_leave_out: impl Fn(&Change) -> bool) -> Result { + let mut all = self.clone(); + if all.apply(changes, Mode::Strict).is_ok() { + *self = all; + return Ok(0); + } + let given = changes.len(); + let mut kept = Vec::with_capacity(given); + for c in changes.drain(..) { + let mut t = self.clone(); + match t.apply(std::slice::from_ref(&c), Mode::Strict) { + Ok(_) => { + *self = t; + kept.push(c); + } + Err(e) if !may_leave_out(&c) => return Err(e), + Err(_) => {} + } + } + let left_out = given - kept.len(); + *changes = kept; + Ok(left_out) } /// The whole state as changes that build it from nothing (pages and their trees in order, diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 26b678176..9a04e54b3 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -77,6 +77,10 @@ pub struct Server { signals: BTreeSet, /// Who spotlights now (a newcomer is told). spotlight: Option, + /// Saved history this server restored or replayed left image entries out (or may give a + /// resuming client other bytes under a key it holds): every resuming client catches up with + /// the snapshot, not the log. + resync: bool, } /// The most client ids a session remembers for reconnecting (the oldest disconnected ones go @@ -127,6 +131,7 @@ impl Server { journal: None, signals: BTreeSet::new(), spotlight: None, + resync: false, } } @@ -148,7 +153,7 @@ impl Server { /// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped /// (clients then catch up with a snapshot). pub fn restore(state: SessionState, token: impl Into, limits: Limits) -> Result { - let replica = Replica::from_snapshot(&state.snapshot)?; + let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?; let mut server = Server::new(replica.document().clone(), token, limits); server.state = replica; server.session = state.session; @@ -158,12 +163,13 @@ impl Server { server.deleted = state.deleted; server.removed = state.removed; let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq)); - // A log saved before the image checks may hold an image entry a resuming client would - // refuse — an invalid image, or other bytes under a key it already holds, which this - // state can't tell apart from a new image. A log that puts images is dropped, so clients - // catch up with the (checked) snapshot instead. - let puts_images = state.log.iter().flat_map(|a| &a.changes).any(|c| matches!(c, Change::Put { entry: Entry::Image(_), .. })); - if contiguous && !puts_images && state.log.last().is_none_or(|a| a.seq == state.seq) { + // History saved before the image checks may hold image entries they refuse. The snapshot + // opens without them, and a saved log putting images may give a resuming client other + // bytes under a key it holds, which this state can't check: either way resuming clients + // catch up with the snapshot. + let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image); + server.resync = left_out > 0 || puts_images; + if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) { for a in state.log { server.remember(a); } @@ -207,9 +213,12 @@ impl Server { if acc.seq != self.seq + 1 { return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq))); } - // A log written before the image checks may hold an image they refuse: the batch - // replays without it, so the edits after it aren't dropped with the rest of the log. - acc.changes = docprops::without_refused_images(self.state.document(), &acc.changes); + // A log written before the image checks may hold an image entry they refuse: the + // batch replays without it, so the edits after it aren't dropped with the rest of + // the log, and resuming clients catch up with the snapshot. + if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 { + self.resync = true; + } self.accept(acc.author, self.seq, &acc.changes)?; self.issued.entry(acc.author).or_default().note(acc.batch_id, false); self.next_client = self.next_client.max(acc.author.saturating_add(1)); @@ -292,7 +301,9 @@ impl Server { // The log holds every batch after `floor`. let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1)); let catch_up = match resume { - Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()), + Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => { + CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()) + } _ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?), }; let (id, secret) = match resume { diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 442a313c5..f350013ce 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -156,11 +156,12 @@ As built: with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 hex of those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at most 256 MiB (`ImageData::MAX_BYTES`, what the importers read), base64 that can't decode is - refused, and a key that already holds an image can't be given other bytes; a snapshot or a - server log written before these checks opens without the images they refuse — judged against - the state they apply to, so a logged replacement is left out too — rather than failing, and a - file published to a server is kept without them; a restarted server whose saved log puts - images catches resuming clients up with its snapshot instead of that log); a + refused, a key that already holds an image can't be given other bytes, and an image a layer or + style still shows can't be removed; a snapshot or a server log written before these checks + opens without the image entries they refuse — judged against the state they apply to, so a + logged replacement or removal is left out too — rather than failing, and a file published to a + server is kept without them; a restarted server that left image entries out, or whose saved log + puts images, catches every resuming client up with its snapshot instead of that log); a board's timer and vote (whole values, `DocProp::Timer` and `DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting at once write different entries). The timer travels as its end time (or, paused, the time left), diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index adc264303..c8a9d4360 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | 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. 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). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), and a key's bytes can't be replaced, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 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). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | @@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server whose saved log puts images catches resuming clients up with its snapshot instead of that log, which it can't check against what each client holds. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | +| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |