fix(collab): reject reverting an active live document (#214)
file.revert is refused while the file is in a live session (hosting, joined, publishing or reconnecting): reverting replaced the document's identity and broke its session. The refusal changes nothing, and reverting works again after leaving. Fixes #198.
This commit is contained in:
parent
8b7435890e
commit
5b52eeeb36
|
|
@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
|
||||
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
|
|
|
|||
|
|
@ -789,6 +789,10 @@ impl Session {
|
|||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
|
||||
if id == "file.revert" && self.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
}
|
||||
branches::sync(self);
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
|
||||
//! editable). Leaving keeps the file open as your copy.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, Status, VIEW_ONLY};
|
||||
use crate::live_tests::{Script, info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
|
|
@ -133,3 +135,149 @@ fn a_view_link_makes_the_file_read_only() {
|
|||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
fn saved_file(s: &mut Session) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-live-revert-{}-{}.figcraft", std::process::id(), s.ds().unwrap().uid));
|
||||
s.execute("file.saveVersion", &json!({"name": "Saved"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Unsaved"})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn revert_refused_unchanged(s: &mut Session, script: &Arc<Mutex<Script>>) {
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let session = s.live().id;
|
||||
let before = format!("{:?}", s.docs);
|
||||
let live = s.live_summary();
|
||||
let journal = s.journal.clone();
|
||||
let last = s.last_command.clone();
|
||||
let message = s.message.clone();
|
||||
let ui = s.ui_requests.clone();
|
||||
let active = s.active;
|
||||
let script_before = {
|
||||
let sc = script.lock().unwrap();
|
||||
(sc.msgs.len(), sc.sent.len(), sc.left)
|
||||
};
|
||||
let result = s.execute("file.revert", &json!({}));
|
||||
assert!(
|
||||
matches!(result, Err(EngineError::Disabled(ref cmd, _)) if cmd == "file.revert"),
|
||||
"expected refusal, got {result:?}; document UID {uid} -> {}, live session {session} -> {}",
|
||||
s.ds().unwrap().uid,
|
||||
s.live().id
|
||||
);
|
||||
assert_eq!(format!("{:?}", s.docs), before, "all document state, including previews and branch tabs, stays intact");
|
||||
assert_eq!(s.live_summary(), live);
|
||||
assert_eq!((s.active, &s.journal, &s.last_command, &s.message, &s.ui_requests), (active, &journal, &last, &message, &ui));
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.msgs.len(), sc.sent.len(), sc.left), script_before, "no sync, presence or leave sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refuses_live_documents_without_losing_access_or_sync() {
|
||||
for state in ["editing", "viewing", "publishing", "reconnecting", "hosting"] {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
if state == "hosting" {
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
} else {
|
||||
s.execute("collab.publish", &json!({"server": "ws://127.0.0.1:47900/"})).unwrap();
|
||||
}
|
||||
let access = if state == "viewing" { Access::View } else { Access::Edit };
|
||||
let (mut server, me, welcome) = serve_as(s.doc().unwrap(), access);
|
||||
if !matches!(state, "publishing" | "hosting") {
|
||||
let mut i = info(Role::Guest, me);
|
||||
i.access = access;
|
||||
push(&script, vec![Event::Welcome { info: i, peers: vec![] }, welcome.clone()]);
|
||||
s.live_poll();
|
||||
}
|
||||
if state == "reconnecting" {
|
||||
push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]);
|
||||
s.live_poll();
|
||||
}
|
||||
let read_only = match state {
|
||||
"viewing" => Some(VIEW_ONLY),
|
||||
"publishing" => Some(PUBLISHING),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "{state}");
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
if let Some(why) = read_only {
|
||||
refused(&mut s, "file.rename", json!({"name": "Refused"}), why);
|
||||
}
|
||||
if matches!(state, "publishing" | "hosting") {
|
||||
if state == "publishing" {
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![] }]);
|
||||
}
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "the welcome still finds its document");
|
||||
}
|
||||
if access == Access::Edit && state != "reconnecting" {
|
||||
s.execute("file.rename", &json!({"name": "Still syncing"})).unwrap();
|
||||
let answers = relay(&script, &mut server, me);
|
||||
assert_eq!(server.document().name, "Still syncing", "{state}");
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refusal_preserves_previews_and_branch_tabs() {
|
||||
let (mut s, script) = session();
|
||||
let text = s.execute("text.create", &json!({"text": "Preview"})).unwrap()["id"].clone();
|
||||
let path = saved_file(&mut s);
|
||||
s.execute("branch.create", &json!({"name": "Working"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let (_, _, welcome) = serve_as(s.doc().unwrap(), Access::Edit);
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
let main_uid = s.ds().unwrap().uid;
|
||||
// A branch has no local save path; refusal keeps its live parent associated.
|
||||
s.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
assert!(s.ds().unwrap().path.is_none());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live_of(main_uid).doc_uid, Some(main_uid));
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_some());
|
||||
// A gesture on a branch has not yet been copied back into its main file.
|
||||
s.docs[1].doc_mut().name = "Branch edits".into();
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
s.execute("version.exitPreview", &json!({})).unwrap();
|
||||
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [text], "fontSize": 32}})).unwrap();
|
||||
assert!(s.ds().unwrap().text_preview.is_some());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let offline_path = saved_file(&mut s);
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
|
||||
assert_eq!(script.lock().unwrap().left, 0);
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live().doc_uid, None);
|
||||
assert_eq!(script.lock().unwrap().left, 1);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(offline_path).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -632,7 +632,10 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
`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.
|
||||
space (`client_id << 32 | counter`). Closing the session's document leaves the session. Revert to
|
||||
saved (`file.revert`) is refused while the document belongs to a session, including publishing,
|
||||
reconnecting and hosting before the welcome. Refusal keeps the document, previews and session
|
||||
unchanged; leave the session first to revert the local file.
|
||||
- Each applied batch wakes the window (the inbox calls `request_repaint`); nothing runs while
|
||||
nobody edits or moves.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
|
|
@ -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. 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. 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. 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); `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 list is per computer: no accounts, teams or projects | 8–17 |
|
||||
|
|
@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
|
||||
|
|
|
|||
Loading…
Reference in New Issue