mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 12:55:16 +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>
70 lines
2.2 KiB
Rust
70 lines
2.2 KiB
Rust
//! 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
|
|
}
|
|
}
|