mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 19:15:42 +00:00
* 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>
215 lines
10 KiB
Rust
215 lines
10 KiB
Rust
//! 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();
|
|
}
|