mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
Live sessions are per document instead of one for the whole app.
- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
installed link, the invite-link registration) plus one `Live` per session. One place resolves
"the session of this document": `Session::live()` (the active document's; status off when it
has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
spotlight and presence all go through the session of the document they are about, so edits and
presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
`(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
link keep them in a shared table with one mailbox per session
(`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
`collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
touching other sessions; until then (or if it fails) it shows on the tab it was started from.
Starting a session for a document replaces only that document's own session. Closing a document
ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
banner, follow frame and read-only reason read the session of the tab in front, per window. What
belongs to one session in the window (cursors shown, your chat box, a report being written) is
dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
`sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
`docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
337 lines
18 KiB
Rust
337 lines
18 KiB
Rust
//! Live sessions are per document, against the scripted link: every open document has its own
|
|
//! session or none, several run at once, the `collab.*` commands and `collab.peers` are the
|
|
//! active document's, a join shows where it was started until its file opens in its own tab, and
|
|
//! closing a document ends its session only.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
|
use figcraft_collab::{Client, Outgoing, Server};
|
|
use figcraft_doc::NodeId;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::live::{Event, PUBLISHING, Presence, Role, Signal, Status};
|
|
use crate::live_tests::{Script, info, peer, push, relay, serve, session};
|
|
use crate::{EngineError, Session};
|
|
|
|
const LINK: &str = "figcraft://join?host=a:1&file=f&token=t";
|
|
|
|
/// Hand `events` to the session as link session `id`'s.
|
|
fn feed(s: &mut Session, script: &Arc<Mutex<Script>>, id: u64, events: Vec<Event>) {
|
|
script.lock().unwrap().session = id;
|
|
push(script, events);
|
|
s.live_poll();
|
|
}
|
|
|
|
fn uid(s: &Session) -> u64 {
|
|
s.ds().unwrap().uid
|
|
}
|
|
|
|
fn id_of(v: &Value) -> u64 {
|
|
v["session"].as_u64().unwrap()
|
|
}
|
|
|
|
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
|
s.execute(cmd, ¶ms).unwrap()
|
|
}
|
|
|
|
/// Host the active document and start syncing it: its uid, its session id, and its session's
|
|
/// server with your client id there.
|
|
fn host(s: &mut Session, script: &Arc<Mutex<Script>>) -> (u64, u64, Server, u32) {
|
|
let id = id_of(&run(s, "collab.host", json!({})));
|
|
let (server, me, welcome) = serve(s.doc().unwrap());
|
|
feed(s, script, id, vec![welcome]);
|
|
assert!(s.live_shared());
|
|
(uid(s), id, server, me)
|
|
}
|
|
|
|
/// Two documents, each hosted and syncing (the second in front): their uids, session ids and
|
|
/// the scripted link.
|
|
fn two_hosted() -> (Session, Arc<Mutex<Script>>, [u64; 2], [u64; 2]) {
|
|
let (mut s, script) = session();
|
|
let (ua, a, ..) = host(&mut s, &script);
|
|
run(&mut s, "file.new", json!({"name": "Second"}));
|
|
let (ub, b, ..) = host(&mut s, &script);
|
|
(s, script, [ua, ub], [a, b])
|
|
}
|
|
|
|
#[test]
|
|
fn each_document_has_its_own_session() {
|
|
let (mut s, script) = session();
|
|
let a = run(&mut s, "collab.host", json!({}));
|
|
let ua = uid(&s);
|
|
// A new document isn't in the first one's session.
|
|
run(&mut s, "file.new", json!({}));
|
|
let ub = uid(&s);
|
|
let off = run(&mut s, "collab.peers", json!({}));
|
|
for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] {
|
|
assert_eq!(off[key], Value::Null, "{key}: {off}");
|
|
}
|
|
assert_eq!((&off["status"], &off["syncing"], &off["readOnly"]), (&json!("off"), &json!(false), &json!(false)));
|
|
assert_eq!(off["peers"], json!([]));
|
|
assert_eq!(off["sessions"].as_array().unwrap().len(), 1);
|
|
assert_eq!(
|
|
(&off["sessions"][0]["session"], &off["sessions"][0]["document"], &off["sessions"][0]["link"]),
|
|
(&a["session"], &json!(0), &a["link"])
|
|
);
|
|
assert_eq!((s.live().id, s.live().status.clone()), (0, Status::Off));
|
|
assert!(s.live_mut().is_none() && s.active_read_only().is_none());
|
|
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here");
|
|
assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped");
|
|
// Hosting it too starts a second session; the first goes on.
|
|
let b = run(&mut s, "collab.host", json!({}));
|
|
assert_ne!(a["session"], b["session"]);
|
|
assert_ne!(a["link"], b["link"]);
|
|
assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2));
|
|
{
|
|
let sc = script.lock().unwrap();
|
|
assert_eq!((sc.hosted.len(), sc.left), (2, 0));
|
|
}
|
|
// People come and go in their own session.
|
|
let (a_id, b_id) = (id_of(&a), id_of(&b));
|
|
feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
|
|
feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
|
|
assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2));
|
|
let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
|
let p = s.live_summary();
|
|
assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"]));
|
|
assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2)));
|
|
// The file in front decides what `collab.peers` and the commands are about.
|
|
run(&mut s, "doc.switch", json!({"index": 0}));
|
|
let p = run(&mut s, "collab.peers", json!({}));
|
|
assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"]));
|
|
assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5);
|
|
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session");
|
|
run(&mut s, "doc.switch", json!({"index": 1}));
|
|
assert!(s.execute("collab.follow", &json!({"client": 5})).is_err());
|
|
assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5)));
|
|
// Stopping ends this file's session only.
|
|
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true);
|
|
assert_eq!(script.lock().unwrap().ended, [b_id]);
|
|
assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live));
|
|
// Someone ending or leaving the other one doesn't bring this one back.
|
|
feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
|
|
assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped");
|
|
// Closing a file ends its session.
|
|
run(&mut s, "file.close", json!({"index": 0, "force": true}));
|
|
s.live_sync();
|
|
assert_eq!(script.lock().unwrap().ended, [b_id, a_id]);
|
|
assert_eq!(s.live.iter().count(), 0);
|
|
assert_eq!(s.message.as_deref(), Some("The live session's file was closed, so you left the session"));
|
|
}
|
|
|
|
#[test]
|
|
fn closing_one_document_leaves_the_others_session_running() {
|
|
let (mut s, script, [ua, ub], [a, b]) = two_hosted();
|
|
run(&mut s, "file.close", json!({"index": 1, "force": true}));
|
|
s.live_sync();
|
|
assert_eq!(script.lock().unwrap().ended, [b]);
|
|
assert_eq!((s.live_of(ua).status.clone(), s.live_of(ub).status.clone()), (Status::Live, Status::Off));
|
|
assert_eq!((uid(&s), s.live().id), (ua, a), "the file left in front shows its own session");
|
|
// It still syncs.
|
|
script.lock().unwrap().msgs_to.clear();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
|
s.live_sync();
|
|
assert_eq!(script.lock().unwrap().msgs_to, [a]);
|
|
}
|
|
|
|
#[test]
|
|
fn edits_and_presence_stay_in_their_session() {
|
|
let (mut s, script) = session();
|
|
let (ua, a, ..) = host(&mut s, &script);
|
|
run(&mut s, "file.new", json!({}));
|
|
let (ub, b, mut server, me) = host(&mut s, &script);
|
|
s.live_sync();
|
|
{
|
|
let mut sc = script.lock().unwrap();
|
|
sc.msgs_to.clear();
|
|
sc.sent_to.clear();
|
|
sc.sent.clear();
|
|
}
|
|
// An edit of the file in front goes to its session, as that file's own undo step.
|
|
let rect = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [rect.0]}));
|
|
s.live_sync();
|
|
assert_eq!(script.lock().unwrap().msgs_to, [b]);
|
|
assert_eq!(s.undo_depth().0, 1);
|
|
assert!(!s.docs.iter().find(|d| d.uid == ua).unwrap().doc.contains(rect));
|
|
let confirmed = relay(&script, &mut server, me);
|
|
feed(&mut s, &script, b, confirmed);
|
|
// Your selection and view go to that session; the other one isn't told anything new.
|
|
{
|
|
let sc = script.lock().unwrap();
|
|
assert_eq!(sc.sent_to, [b]);
|
|
assert_eq!(sc.sent[0].selection, Some(vec![rect]));
|
|
}
|
|
// With the other file in front: its session gets your presence there, this one gets none
|
|
// (no cursor, selection or view of a file you don't look at).
|
|
run(&mut s, "doc.switch", json!({"index": 0}));
|
|
assert_eq!(s.undo_depth().0, 0, "undo is each file's own");
|
|
run(&mut s, "collab.cursor", json!({"x": 3, "y": 4}));
|
|
{
|
|
let sc = script.lock().unwrap();
|
|
let last = |id: u64| sc.sent_to.iter().zip(&sc.sent).rev().find(|(to, _)| **to == id).map(|(_, p)| p.clone()).unwrap();
|
|
assert_eq!(last(b), Presence::default());
|
|
assert_eq!((last(a).cursor.map(|c| (c.1, c.2)), last(a).selection), (Some((3.0, 4.0)), Some(vec![])));
|
|
}
|
|
assert_eq!((s.live().cursor, s.live_of(ub).cursor), (Some((3.0, 4.0)), None));
|
|
// What others do in the file behind arrives there, and only there.
|
|
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
|
|
let (ada, 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();
|
|
let mut other = Client::join(w).unwrap();
|
|
let mut doc = other.document().clone();
|
|
doc.node_mut(rect).unwrap().name = "Theirs".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();
|
|
let before = s.ds().unwrap().revision;
|
|
feed(&mut s, &script, b, remote);
|
|
assert_eq!(s.docs.iter().find(|d| d.uid == ub).unwrap().doc.node(rect).unwrap().name, "Theirs");
|
|
assert_eq!(s.ds().unwrap().revision, before, "the file in front didn't change");
|
|
assert_eq!(uid(&s), ua, "and stays in front");
|
|
}
|
|
|
|
#[test]
|
|
fn a_join_shows_where_it_started_until_its_file_opens() {
|
|
let (mut s, script) = session();
|
|
let first = uid(&s);
|
|
let r = run(&mut s, "collab.join", json!({"link": LINK}));
|
|
let join = id_of(&r);
|
|
assert_eq!((&r["status"], &r["document"]), (&json!("connecting"), &Value::Null));
|
|
assert_eq!(s.live().status, Status::Connecting);
|
|
// Another file doesn't show it; the list of sessions does.
|
|
run(&mut s, "file.new", json!({}));
|
|
let p = s.live_summary();
|
|
assert_eq!((&p["status"], &p["sessions"][0]["status"], &p["sessions"][0]["document"]), (&json!("off"), &json!("connecting"), &Value::Null));
|
|
// Its file opens as a tab of its own, in front, with the session.
|
|
let mut theirs = figcraft_doc::Document::new("Team file");
|
|
theirs.repair();
|
|
let (_, me, welcome) = serve(&theirs);
|
|
feed(&mut s, &script, join, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![peer(1, "Hana")] }, welcome]);
|
|
assert_eq!((s.docs.len(), s.active), (3, 2));
|
|
assert_eq!((s.doc().unwrap().name.as_str(), s.live().id, s.live().syncing()), ("Team file", join, true));
|
|
assert_eq!(s.live_summary()["document"], 2);
|
|
assert_eq!(s.live_of(first).status, Status::Off, "the tab it was started from is in no session");
|
|
assert_eq!(s.live.iter().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn joining_from_a_file_in_a_session_leaves_that_session_alone() {
|
|
let (mut s, script) = session();
|
|
let host = id_of(&run(&mut s, "collab.host", json!({})));
|
|
let first = uid(&s);
|
|
// A link that can't be used changes nothing.
|
|
assert!(matches!(s.execute("collab.join", &json!({"link": "http://x"})), Err(EngineError::BadParams { .. })));
|
|
assert_eq!((s.live.iter().count(), script.lock().unwrap().left), (1, 0));
|
|
let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
|
let p = run(&mut s, "collab.peers", json!({}));
|
|
assert_eq!((&p["status"], &p["role"], id_of(&p)), (&json!("live"), &json!("host"), host), "this file still shows its own session");
|
|
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
|
|
// The join fails: the reason is said, and the file's own session is as it was.
|
|
feed(&mut s, &script, join, vec![Event::Status(Status::Failed { reason: "wrong session token".into() })]);
|
|
assert_eq!(s.message.as_deref(), Some("wrong session token"));
|
|
assert_eq!((s.live().status.clone(), s.live().role()), (Status::Live, Some(Role::Host)));
|
|
let p = s.live_summary();
|
|
assert_eq!((&p["sessions"][1]["status"], &p["sessions"][1]["reason"]), (&json!("failed"), &json!("wrong session token")));
|
|
assert_eq!(script.lock().unwrap().ended, [join], "the link is told the failed session is over");
|
|
// The next join from here takes the failed one's place.
|
|
let again = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
|
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [host, again]);
|
|
// From a file that isn't in a session, a failed join shows there, and nowhere else.
|
|
run(&mut s, "file.new", json!({}));
|
|
let idle = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
|
feed(&mut s, &script, idle, vec![Event::Status(Status::Failed { reason: "can't reach the host".into() })]);
|
|
assert_eq!(s.live_summary()["reason"], "can't reach the host");
|
|
assert_eq!(s.live_of(first).status, Status::Live);
|
|
run(&mut s, "file.new", json!({}));
|
|
assert_eq!(s.live_summary()["status"], "off", "a new file starts with no session");
|
|
run(&mut s, "doc.switch", json!({"index": 1}));
|
|
assert_eq!(run(&mut s, "collab.leave", json!({}))["ended"], false);
|
|
assert_eq!(s.live_summary()["status"], "off");
|
|
}
|
|
|
|
#[test]
|
|
fn a_join_outlives_the_tab_it_was_started_from() {
|
|
let (mut s, script) = session();
|
|
run(&mut s, "file.new", json!({}));
|
|
let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
|
let failed = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
|
feed(&mut s, &script, failed, vec![Event::Status(Status::Failed { reason: "no".into() })]);
|
|
run(&mut s, "file.close", json!({"index": 1, "force": true}));
|
|
s.live_sync();
|
|
// The one on its way goes on, shown on the file now in front; the failed one is gone.
|
|
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [join]);
|
|
assert_eq!((s.live().id, s.live().status.clone()), (join, Status::Connecting));
|
|
assert_eq!(script.lock().unwrap().ended, [failed, failed], "told when it failed and when its tab closed; never the join on its way");
|
|
}
|
|
|
|
#[test]
|
|
fn publishing_one_file_keeps_the_others_editable() {
|
|
let (mut s, script) = session();
|
|
let host = id_of(&run(&mut s, "collab.host", json!({})));
|
|
let first = uid(&s);
|
|
run(&mut s, "file.new", json!({}));
|
|
let r = run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"}));
|
|
assert_eq!((&r["status"], &r["readOnly"], &r["document"]), (&json!("connecting"), &json!(true), &json!(1)));
|
|
assert_eq!(s.active_read_only(), Some(PUBLISHING));
|
|
assert!(s.live_read_only(first).is_none());
|
|
run(&mut s, "doc.switch", json!({"index": 0}));
|
|
let p = s.live_summary();
|
|
assert_eq!((&p["role"], &p["readOnly"], id_of(&p)), (&json!("host"), &json!(false), host));
|
|
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
|
// Publishing a file again replaces its own session, not the other file's.
|
|
run(&mut s, "doc.switch", json!({"index": 1}));
|
|
let again = id_of(&run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"})));
|
|
assert_eq!(script.lock().unwrap().ended, [id_of(&r)]);
|
|
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [host, again]);
|
|
}
|
|
|
|
#[test]
|
|
fn chat_and_spotlight_stay_in_their_session() {
|
|
let (mut s, script, [ua, ub], [a, b]) = two_hosted();
|
|
// Only the second file's host reads signals.
|
|
feed(&mut s, &script, b, vec![Event::Signal(1, Signal::ready()), Event::PeerJoined(peer(3, "Ada"))]);
|
|
script.lock().unwrap().msgs_to.clear();
|
|
assert_eq!(run(&mut s, "collab.cursorChat", json!({"text": "here"}))["chat"], "here");
|
|
assert_eq!(run(&mut s, "collab.spotlight", json!({"on": true}))["spotlighting"], true);
|
|
assert_eq!(script.lock().unwrap().msgs_to, [b, b]);
|
|
feed(&mut s, &script, b, vec![Event::Signal(3, Signal::chat("hello"))]);
|
|
assert_eq!((s.live().chat.as_str(), s.live().spotlighting, s.live().chats.get(&3).map(String::as_str)), ("here", true, Some("hello")));
|
|
// The first file's session has none of it, and can't send any.
|
|
run(&mut s, "doc.switch", json!({"index": 0}));
|
|
assert_eq!(uid(&s), ua);
|
|
let p = s.live_summary();
|
|
assert_eq!((&p["chat"], &p["spotlighting"], &p["signals"], id_of(&p)), (&json!(""), &json!(false), &json!(false), a));
|
|
assert!(matches!(s.execute("collab.cursorChat", &json!({"text": "hi"})), Err(EngineError::Disabled(..))));
|
|
assert_eq!(run(&mut s, "collab.cursorChat", json!({}))["open"], false);
|
|
assert!(s.live_of(ub).spotlighting, "still spotlighting in the other file");
|
|
// A report goes to the session of the file in front, which here isn't a server's.
|
|
assert!(s.execute("collab.report", &json!({"reason": "spam"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn collab_commands_survive_junk_with_several_sessions() {
|
|
let junk = [
|
|
json!(null),
|
|
json!([1, 2]),
|
|
json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}, "session": -1, "document": 1e300}),
|
|
json!({"link": "", "client": 4_294_967_296u64, "session": "x", "document": {}}),
|
|
json!({"text": 5, "on": "yes"}),
|
|
json!({"text": "\u{0}\u{7f}".repeat(10_000), "on": null}),
|
|
];
|
|
for cmd in crate::all_commands().iter().filter(|c| c.id.starts_with("collab.")) {
|
|
for j in &junk {
|
|
let (mut s, script, _, [a, _]) = two_hosted();
|
|
run(&mut s, "collab.join", json!({"link": LINK}));
|
|
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd.id, j) {
|
|
panic!("{} panicked on {j}: {msg}", cmd.id);
|
|
}
|
|
// Whatever it did, the sessions still take what arrives.
|
|
feed(&mut s, &script, a, vec![Event::PeerJoined(peer(9, "Late")), Event::Status(Status::Off)]);
|
|
s.live_sync();
|
|
assert!(s.live.get(a).is_none());
|
|
}
|
|
}
|
|
}
|