mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
M9: online files: crash recovery never drops an unsent edit
The recovery copy of one of your online files is no longer discarded when the file reopens from its server. It is kept until the outcome is known: - the server has everything in it: the copy goes; - the copy has changes that never reached the server and nothing else changed there: they are applied on top as one edit and sent to everyone; - anything else (others changed the file, the server forgot the client): the copy opens beside the file as "<name> (recovered copy)", with a toast; - the server can't be reached: the copy opens as an unsaved document. The note beside a copy is written after it and records who the app was in the file and the server's last change it had. The app joins again as that client (the first hello resumes; a new sync client numbers its batches after the ones the server processed), so the layers it made are its own to send. The old copy is removed only once this run has copied what it became. collab.recoverFile; a tooltip on the tab mark; docs.
This commit is contained in:
+2
-2
@@ -135,7 +135,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
|
||||
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
|
||||
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
|
||||
crash reopens such a file from its server. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
|
||||
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
|
||||
upgrade only to tell which files are still there. The six draft catalogs translate every string again
|
||||
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
|
||||
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
|
||||
@@ -286,7 +286,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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. `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. 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`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
|
||||
| 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. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
|
||||
|
||||
@@ -137,8 +137,13 @@ fn main() -> eframe::Result {
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
|
||||
*recovery::lock(&recovery) = recovery_root.as_deref().and_then(recovery::Recovery::start);
|
||||
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
|
||||
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
|
||||
// Copies of online files stay until their files arrive from their servers.
|
||||
if let Some(r) = copies.as_mut() {
|
||||
r.adopt(recovered.waiting);
|
||||
}
|
||||
*recovery::lock(&recovery) = copies;
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
@@ -150,7 +155,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered);
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
}
|
||||
|
||||
+121
-43
@@ -6,10 +6,15 @@
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files, in step with its server, has a note beside its
|
||||
//! copy saying which (`<uid>.online`: the server and the file's id, never a token): the next start
|
||||
//! opens that file again as its owner (`collab.openFile`) in place of the stale copy. Without
|
||||
//! the note, or when the file is no longer recorded, the copy opens as any other.
|
||||
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
|
||||
//! after the copy it describes): the server, the file's id, and who the app was in the file with
|
||||
//! the last change of the server's it had; never a token. The next start joins that file again
|
||||
//! as its owner (`collab.recoverFile`) and **keeps the copy until the outcome is known**: the
|
||||
//! server has everything in it (the copy goes), the copy's own unconfirmed changes were put on
|
||||
//! top of the server's file and sent, the copy was opened beside the file because both had
|
||||
//! changed, or the file couldn't be joined and the copy was opened as any other. Whatever a copy
|
||||
//! became is copied into this run's folder before the old copy is removed. Without the note, or
|
||||
//! when the file is no longer recorded, the copy opens as any other.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
@@ -40,6 +45,25 @@ pub struct Recovery {
|
||||
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
|
||||
/// When (UI clock) the next copies are due.
|
||||
next: f64,
|
||||
/// Copies of online files from earlier runs, kept until their outcome is known.
|
||||
waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
/// A copy of one of your online files whose file is being joined again as session `session`.
|
||||
pub struct Waiting {
|
||||
session: u64,
|
||||
copy: PathBuf,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile.
|
||||
_lock: Arc<File>,
|
||||
}
|
||||
|
||||
/// What [`recover_all`] brought back.
|
||||
#[derive(Default)]
|
||||
pub struct Recovered {
|
||||
/// Documents opened from their copies.
|
||||
pub copies: usize,
|
||||
/// Copies of online files waiting for their file ([`Recovery::adopt`]).
|
||||
pub waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
@@ -49,20 +73,50 @@ impl Recovery {
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::create(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
}
|
||||
|
||||
/// Keep earlier runs' copies of online files until their outcome is known.
|
||||
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
|
||||
self.waiting.extend(waiting);
|
||||
}
|
||||
|
||||
/// Copy the documents that changed since their last copy and drop the copies of documents that
|
||||
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
self.settle(s);
|
||||
// A join on its way settles within its timeouts: look again soon.
|
||||
let settling = (!self.waiting.is_empty()).then_some(0.5);
|
||||
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
|
||||
if now < self.next {
|
||||
return pending.then_some(self.next - now);
|
||||
return pending.then_some(self.next - now).or(settling);
|
||||
}
|
||||
self.write(s, false);
|
||||
self.next = now + INTERVAL;
|
||||
None
|
||||
settling
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
|
||||
fn settle(&mut self, s: &Session) {
|
||||
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
|
||||
return;
|
||||
}
|
||||
self.write(s, true);
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
|
||||
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
|
||||
self.waiting = waiting;
|
||||
for w in done {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
// The folder goes with its last copy (while another waits there its lock is held).
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
|
||||
@@ -77,18 +131,26 @@ impl Recovery {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Which online file it is (a line each: the server, the file's id), while it is one.
|
||||
let _ = match s.online_of(d.uid) {
|
||||
Some((server, file)) => std::fs::write(¬e, format!("{server}\n{file}")),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
let entry = self.written.entry(d.uid).or_insert((0, None));
|
||||
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
|
||||
if entry.0 == d.revision || busy {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let job = move || write_atomic(&path, &doc);
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc) {
|
||||
return;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
};
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
@@ -110,7 +172,8 @@ impl Recovery {
|
||||
});
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose.
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
@@ -125,13 +188,17 @@ impl Recovery {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// The copy at `file` is of one of your online files that was in step with its server: open that
|
||||
/// file again as its owner. False when it wasn't one, or can't be opened that way now.
|
||||
fn reopen_online(file: &Path, s: &mut Session) -> bool {
|
||||
let Ok(note) = std::fs::read_to_string(file.with_extension(ONLINE)) else { return false };
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
let note = std::fs::read_to_string(file.with_extension(ONLINE)).ok()?;
|
||||
let mut lines = note.lines().map(str::trim);
|
||||
let (Some(server), Some(id)) = (lines.next(), lines.next()) else { return false };
|
||||
s.execute("collab.openFile", &json!({"server": server, "file": id})).is_ok()
|
||||
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
|
||||
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
|
||||
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
|
||||
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
@@ -139,36 +206,44 @@ fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
|
||||
/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy
|
||||
/// before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
if std::fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
/// unsaved copies.
|
||||
#[cfg(test)]
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
recover_all(root, s).copies
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. Returns how many documents came back as unsaved
|
||||
/// copies (your online files open again from their servers instead and aren't counted).
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut open = |file: &Path, s: &mut Session| {
|
||||
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
|
||||
/// instead while its file is joined again ([`Recovered::waiting`]).
|
||||
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
let mut out = Recovered::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return out };
|
||||
let mut open = |file: &Path, lock: Option<&Arc<File>>, s: &mut Session| {
|
||||
if file.extension().is_none_or(|e| e != "figcraft") {
|
||||
return;
|
||||
}
|
||||
// One of your online files comes back from its server; anything else from its copy.
|
||||
let online = reopen_online(file, s);
|
||||
if online || s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
n += usize::from(!online);
|
||||
// One of your online files comes back from its server, its copy kept until then.
|
||||
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
|
||||
out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() });
|
||||
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.copies += 1;
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
open(&path, s);
|
||||
open(&path, None, s);
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
@@ -176,23 +251,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
}
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
if f.extension().is_some_and(|e| e == "tmp") {
|
||||
// A copy cut off mid-write; the one before it is still there.
|
||||
let _ = std::fs::remove_file(f);
|
||||
} else {
|
||||
open(f, s);
|
||||
open(f, Some(&lock), s);
|
||||
}
|
||||
}
|
||||
// The notes go with their copies (one whose copy couldn't be read stays with it).
|
||||
// The notes go with their copies (one whose copy stays, stays with it).
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
drop(lock);
|
||||
// Empty now unless a copy couldn't be read (kept for the next start).
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
// Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held).
|
||||
if Arc::strong_count(&lock) == 1 {
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
}
|
||||
}
|
||||
n
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
//! Recovery of your online files: a document that was one of them, in step with its server, when
|
||||
//! a run ended without closing opens again from the server as its owner (`collab.openFile`), not
|
||||
//! as a stale unsaved copy; the note beside its copy names the server and file and never a token;
|
||||
//! and without the record the copy opens as any other.
|
||||
//! Recovery of your online files never loses an edit. The copy of a document that was one of them
|
||||
//! when a run ended without closing has a note beside it (the server, the file, who the app was
|
||||
//! there and the server's last change it had; never a token). The next start joins the file again
|
||||
//! as its owner and keeps the copy until the outcome is known: the copy is ahead of the server
|
||||
//! (its changes are applied on top and sent, as the client the app was), the same (it goes), both
|
||||
//! changed (it opens beside the file as a recovered copy), or the server can't be reached (it
|
||||
//! opens as any other copy). Without the record the copy opens as any other.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo};
|
||||
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Resume, Role, SessionInfo, Status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
|
||||
|
||||
/// The joins asked for, and the events handed out next (as the session started last).
|
||||
/// What the link was asked, what the app sent, and the events handed out next (as the session
|
||||
/// started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<String>,
|
||||
joined: Vec<(String, Option<Resume>)>,
|
||||
sent: Vec<ClientMsg>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
@@ -31,10 +37,13 @@ impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
fn join(&mut self, session: u64, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
fn join_resuming(&mut self, session: u64, link: &str, _: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
s.joined.push((link.to_string(), resume));
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
|
||||
@@ -43,7 +52,9 @@ impl LiveLink for Link {
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn send(&mut self, _: u64, msg: ClientMsg) {
|
||||
self.0.lock().unwrap().sent.push(msg);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
@@ -51,34 +62,23 @@ impl LiveLink for Link {
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
fn session() -> (Session, Shared) {
|
||||
let mut s = Session::new();
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
let script = Shared::default();
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
fn root(name: &str) -> PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
r
|
||||
}
|
||||
|
||||
/// A run whose open file was shared online and is in step with the server, then edited.
|
||||
fn shared_and_edited() -> Session {
|
||||
let (mut s, script) = session();
|
||||
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
|
||||
s.execute("collab.share", &json!({})).unwrap();
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: "f1".into(),
|
||||
name: "Homepage".into(),
|
||||
edit: EDIT.into(),
|
||||
view: String::new(),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: None,
|
||||
};
|
||||
let info = SessionInfo {
|
||||
fn info() -> SessionInfo {
|
||||
SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
@@ -90,47 +90,242 @@ fn shared_and_edited() -> Session {
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone joins `server` (as the client `resume` names, if it knows it): their id and welcome.
|
||||
fn join(server: &mut Server, resume: Option<Resume>) -> (u32, ServerMsg) {
|
||||
let ClientMsg::Hello(mut hello) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
|
||||
hello.resume = resume;
|
||||
let (id, outs) = server.join(hello).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
(id, welcome)
|
||||
}
|
||||
|
||||
/// What the app sent since the last call reaches the server; whether every batch was accepted.
|
||||
/// The server's confirmations wait for the app's next poll.
|
||||
fn deliver(script: &Shared, server: &mut Server, from: u32) -> bool {
|
||||
let sent = std::mem::take(&mut script.lock().unwrap().sent);
|
||||
let mut accepted = true;
|
||||
for m in sent {
|
||||
for o in server.receive(from, m) {
|
||||
match o {
|
||||
Outgoing::To(_, ServerMsg::Reject { .. }) => accepted = false,
|
||||
Outgoing::All(m @ ServerMsg::Changes { .. }) => script.lock().unwrap().events.push(Event::Sync(m)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
}
|
||||
|
||||
fn layers(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.node(doc.pages[0]).map_or(0, |p| p.children.len())
|
||||
}
|
||||
|
||||
/// A run whose open file was shared online: the app, what it sent, the server and the app's id
|
||||
/// there. One rectangle is on the server.
|
||||
fn shared() -> (Session, Shared, Server, u32) {
|
||||
let (mut s, script) = session();
|
||||
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
|
||||
s.execute("collab.share", &json!({})).unwrap();
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: "f1".into(),
|
||||
name: "Homepage".into(),
|
||||
edit: EDIT.into(),
|
||||
view: String::new(),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: None,
|
||||
};
|
||||
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(me) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(me).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info, peers: vec![] }, Event::Sync(welcome)]);
|
||||
let (me, welcome) = join(&mut server, None);
|
||||
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
s.live_poll();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.docs[0].is_dirty() && s.online_of(s.docs[0].uid).is_some());
|
||||
s
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
s.live_poll();
|
||||
assert_eq!(layers(server.document()), 1);
|
||||
(s, script, server, me)
|
||||
}
|
||||
|
||||
/// The run ends without closing, its copies written; the folder it leaves (as another process's).
|
||||
fn crash(s: &Session, root: &Path) -> PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies(dir: &Path, ext: &str) -> usize {
|
||||
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == ext)).count())
|
||||
}
|
||||
|
||||
/// The next run: only the preferences survive. Its copies of online files wait for their files.
|
||||
fn restart(crashed: &Session, root: &Path) -> (Session, Shared, Recovery, usize) {
|
||||
let (mut next, script) = session();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, script, r, recovered.copies)
|
||||
}
|
||||
|
||||
/// The file arrives in the next run from `server`, as the join asked.
|
||||
fn arrive(next: &mut Session, script: &Shared, server: &mut Server) -> u32 {
|
||||
let resume = script.lock().unwrap().joined.last().and_then(|j| j.1);
|
||||
let (id, welcome) = join(server, resume);
|
||||
script.lock().unwrap().events.extend([Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
next.live_poll();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_file_comes_back_from_its_server_not_from_the_stale_copy() {
|
||||
let root = root("reopen");
|
||||
let crashed = shared_and_edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&crashed, true);
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let note = std::fs::read_to_string(dir.join(format!("{}.online", crashed.docs[0].uid))).unwrap();
|
||||
assert_eq!(note, format!("{SERVER}\nf1"), "which file it is");
|
||||
fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let root = root("ahead");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// An edit the server never got (the connection was down, or the app died first).
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
|
||||
let at = s.online_of(s.docs[0].uid).unwrap().2;
|
||||
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
|
||||
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
|
||||
// The run ends without closing; the next one has the preferences (with the record).
|
||||
let (mut next, script, mut r, opened) = restart(&s, &root);
|
||||
assert_eq!(opened, 0, "nothing comes back as a plain copy");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [(link, resume)] = sc.joined.as_slice() else { panic!("joined {}", sc.joined.len()) };
|
||||
assert_eq!(link, EDIT, "joined again as its owner");
|
||||
assert_eq!(*resume, Some(Resume { seq: u64::MAX, ..at }), "as the client it was, asking for the whole file");
|
||||
}
|
||||
// The copy stays until the outcome is known.
|
||||
assert!(r.tick(&next, 0.0).is_some(), "it looks again while the file is on its way");
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1));
|
||||
// The file arrives: the server lacks the ellipse; it goes on top as one edit and is sent.
|
||||
let id = arrive(&mut next, &script, &mut server);
|
||||
assert_eq!(id, me, "the server gave the id back");
|
||||
assert_eq!(next.message.take().as_deref(), Some("Recovered your changes to Homepage that hadn't reached the server"));
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "in the file's own tab, no copy beside it");
|
||||
assert!(deliver(&script, &mut server, id), "the server takes it (new layers are that client's to create)");
|
||||
assert_eq!(layers(server.document()), 2, "everyone gets the edit");
|
||||
assert_eq!(next.execute("collab.peers", &json!({})).unwrap()["status"], "live");
|
||||
// Only now the old copy goes, after this run has copied what it became.
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists(), "the crashed run's folder is gone");
|
||||
let mine = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&mine, "figcraft"), copies(&mine, "online")), (1, 1), "covered by this run's own copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_the_server_already_has_is_discarded() {
|
||||
let root = root("equal");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// Sent and accepted, but the app died before it heard so.
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
assert_eq!(copies(&left, "figcraft"), 1);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "the file as the server has it");
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing to send");
|
||||
assert!(!next.docs[1].is_dirty());
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_cant_be_merged_opens_beside_the_file() {
|
||||
let root = root("conflict");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
// Meanwhile someone else changes the file on the server.
|
||||
let (other, welcome) = join(&mut server, None);
|
||||
let mut them = Client::join(welcome).unwrap();
|
||||
let mut doc = them.document().clone();
|
||||
let page = doc.pages[0];
|
||||
doc.create(page, figcraft_engine::doc::NodeKind::Rectangle, "Theirs", 50.0, 50.0, 5.0, 5.0).unwrap();
|
||||
let batch = them.edit(&doc).unwrap().unwrap();
|
||||
assert!(!server.receive(other, batch).iter().any(|o| matches!(o, Outgoing::To(_, ServerMsg::Reject { .. }))));
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
// The server's file is untouched; the copy is a tab of its own, named as what it is.
|
||||
let names: Vec<&str> = next.docs.iter().map(|d| d.doc.name.as_str()).collect();
|
||||
assert_eq!(names, ["Untitled", "Homepage", "Homepage (recovered copy)"]);
|
||||
assert_eq!(next.active, 1, "the file stays in front");
|
||||
assert_eq!(layers(&next.docs[1].doc), 2, "the rectangle and theirs");
|
||||
assert_eq!(layers(&next.docs[2].doc), 2, "the rectangle and the ellipse that never got there");
|
||||
assert!(next.docs[2].is_dirty() && next.docs[2].path.is_none());
|
||||
assert_eq!(
|
||||
next.message.take().as_deref(),
|
||||
Some("Homepage changed on the server before your last changes reached it: they are open beside it as “Homepage (recovered copy)”")
|
||||
);
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing is forced onto the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1, "the recovered tab has this run's copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_whose_server_cant_be_reached_opens_as_any_other() {
|
||||
let root = root("offline");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
r.tick(&next, 0.0);
|
||||
assert_eq!(copies(&left, "figcraft"), 1, "kept while the join is on its way");
|
||||
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "Couldn't reach the server (timed out)".into() }));
|
||||
next.live_poll();
|
||||
let ds = next.docs.last().unwrap();
|
||||
assert_eq!((ds.doc.name.as_str(), layers(&ds.doc), ds.is_dirty(), ds.path.is_none()), ("Homepage", 2, true, true));
|
||||
assert_eq!(next.active, next.docs.len() - 1);
|
||||
assert!(next.message.take().unwrap_or_default().contains("your recovered copy is open"));
|
||||
assert_eq!(next.prefs.online_files.len(), 1, "the record stays: the file is still yours on the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1);
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_forgot_the_client_gets_no_layers_it_would_refuse() {
|
||||
let root = root("forgot");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
crash(&s, &root);
|
||||
// The server lost its session (restored from a backup): the same file, another session, so
|
||||
// the app joins as someone new and can't create the layers of who it was.
|
||||
let mut restored = Server::new(Session::new().doc().unwrap().clone(), "", Limits::default());
|
||||
let (mut next, script, r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut restored);
|
||||
assert_eq!(next.docs.last().map(|d| d.doc.name.as_str()), Some("Homepage (recovered copy)"));
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
|
||||
drop(r);
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
|
||||
assert_eq!(recover(&root, &mut next), 0, "nothing comes back as an unsaved copy");
|
||||
assert_eq!(script.lock().unwrap().joined, [EDIT], "it is joined again as its owner");
|
||||
assert!(next.docs.is_empty(), "its tab opens when the server's copy arrives");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone, note and all");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_record_the_copy_opens_as_any_other() {
|
||||
let root = root("fallback");
|
||||
let crashed = shared_and_edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&crashed, true);
|
||||
drop(r);
|
||||
let (s, _, _, _) = shared();
|
||||
crash(&s, &root);
|
||||
// The record was forgotten (or the preferences lost): the copy is what there is.
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
@@ -142,19 +337,18 @@ fn without_its_record_the_copy_opens_as_any_other() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_goes_when_the_file_leaves_its_session_or_is_closed() {
|
||||
fn the_note_follows_its_copy_and_goes_when_the_file_leaves_its_session() {
|
||||
let root = root("note");
|
||||
let mut s = shared_and_edited();
|
||||
let (mut s, _, _, _) = shared();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let notes = |d: &std::path::Path| std::fs::read_dir(d).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "online")).count();
|
||||
assert_eq!(notes(&dir), 1);
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 1));
|
||||
// Left: the copy is an ordinary one from the next write on.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(notes(&dir), 0);
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 0));
|
||||
r.finish();
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -221,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
}
|
||||
|
||||
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
|
||||
let resume: Arc<Resuming> = Arc::default();
|
||||
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
|
||||
let mut attempt = 0u32;
|
||||
let mut was_live = false;
|
||||
let mut pending: Option<Presence> = None;
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct GuestConfig {
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
pub attempts: u32,
|
||||
/// Join as the client you were (an app that starts over after a crash): the first hello
|
||||
/// carries it, and a server that still knows the id gives it back.
|
||||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
impl GuestConfig {
|
||||
@@ -54,6 +57,7 @@ impl GuestConfig {
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::time::Duration;
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionId,
|
||||
SessionInfo, Shared,
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
|
||||
SessionId, SessionInfo, Shared,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
@@ -161,8 +161,12 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
|
||||
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
let cfg = self.guest_config(invite, code, name);
|
||||
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
|
||||
let inbox = self.sessions.inbox();
|
||||
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
|
||||
@@ -84,7 +84,7 @@ impl Client {
|
||||
|
||||
/// Start from the server's `Welcome` (which must carry a snapshot).
|
||||
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
|
||||
return Err(Error::Protocol("expected a welcome with the document".into()));
|
||||
};
|
||||
let mut confirmed = Replica::from_snapshot(&snapshot)?;
|
||||
@@ -97,7 +97,9 @@ impl Client {
|
||||
confirmed,
|
||||
seq,
|
||||
pending: VecDeque::new(),
|
||||
next_batch: 1,
|
||||
// After the batches the server already processed for this id (a client that starts
|
||||
// over under the id it had, e.g. after its app crashed: lower ids would be ignored).
|
||||
next_batch: processed.last_batch.saturating_add(1),
|
||||
next_id,
|
||||
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
|
||||
presence: BTreeMap::new(),
|
||||
|
||||
@@ -87,9 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
|s, p| {
|
||||
let path = req_string("file.recover", p, "path")?;
|
||||
let loaded =
|
||||
read(&path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved());
|
||||
s.docs.push(recovered(&path)?);
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}
|
||||
@@ -414,6 +412,12 @@ fn restore_version(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
|
||||
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
|
||||
@@ -46,7 +46,7 @@ use figcraft_collab::code::{self, Code};
|
||||
pub use figcraft_collab::message::Signal;
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
|
||||
|
||||
pub use figcraft_collab::message::{Access, HostInfo, utc_date};
|
||||
pub use figcraft_collab::message::{Access, HostInfo, Resume, utc_date};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -446,6 +446,12 @@ pub trait LiveLink: Send {
|
||||
/// Start joining the session behind an invite link, as session `session`. Errors are for
|
||||
/// links that can't be used; they never echo the link (it holds the token).
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> std::result::Result<(), String>;
|
||||
/// [`join`](Self::join) as the client `resume` names, when the server still knows it (an app
|
||||
/// that starts over after a crash keeps its id, so what it made before is its own to send).
|
||||
/// A link that can't resume joins as someone new.
|
||||
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, _resume: Option<Resume>) -> std::result::Result<(), String> {
|
||||
self.join(session, link, name)
|
||||
}
|
||||
/// Start uploading `opts.doc` to a server as a new file, then join it with its edit link, as
|
||||
/// session `session`: [`Event::Published`] with its links, then the welcome. Errors are for
|
||||
/// addresses that can't be used; they never echo the key.
|
||||
@@ -638,6 +644,9 @@ pub struct Lives {
|
||||
pub unsaved: bool,
|
||||
/// Servers that couldn't say what became of your files at the last `collab.files {refresh}`.
|
||||
pub servers: crate::online::Problems,
|
||||
/// Recovery copies of your online files waiting for their file to arrive from its server
|
||||
/// (`collab.recoverFile`).
|
||||
pub(crate) recovering: Vec<crate::online::Recovering>,
|
||||
}
|
||||
|
||||
impl Lives {
|
||||
@@ -666,6 +675,12 @@ impl Lives {
|
||||
self.sessions.get(&id)
|
||||
}
|
||||
|
||||
/// A recovery copy started with session `id` still waits for its outcome (the app keeps the
|
||||
/// copy's file until it doesn't).
|
||||
pub fn recovering(&self, id: SessionId) -> bool {
|
||||
self.recovering.iter().any(|r| r.session == id)
|
||||
}
|
||||
|
||||
/// Every session, oldest first.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Live> {
|
||||
self.sessions.values()
|
||||
@@ -916,6 +931,7 @@ impl Session {
|
||||
self.live_deferred(id);
|
||||
self.live_follow_apply(id);
|
||||
}
|
||||
self.online_recoveries();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1381,6 +1397,10 @@ impl Session {
|
||||
/// `mine` picks: a window calls it every frame it draws, after input, for the documents it
|
||||
/// holds.
|
||||
pub fn live_sync_in(&mut self, mine: impl Fn(Option<u64>) -> bool) {
|
||||
// A recovery copy whose session was left has its outcome too.
|
||||
if !self.live.recovering.is_empty() {
|
||||
self.online_recoveries();
|
||||
}
|
||||
if self.live.sessions.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1805,15 +1825,23 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => req_string(CMD, p, "link").map_err(|_| bad(CMD, "give the join `code` you were told, or the invite `link`"))?,
|
||||
};
|
||||
let link = join_target(&s.prefs.publish_server, &typed).map_err(|e| bad(CMD, e))?;
|
||||
let id = start_join(s, CMD, &link, None, None)?;
|
||||
let id = start_join(s, CMD, &link, None, None, None)?;
|
||||
Ok(s.live.get(id).map_or(Value::Null, |l| s.summary(l)))
|
||||
}
|
||||
|
||||
/// Start joining the session behind `link` for command `cmd`: the new session. `published` and
|
||||
/// `share`: the file is yours on a server (`collab.openFile`), with what others join it with.
|
||||
pub(crate) fn start_join(s: &mut Session, cmd: &str, link: &str, published: Option<Published>, share: Option<Share>) -> Result<SessionId> {
|
||||
/// `share`: the file is yours on a server (`collab.openFile`), with what others join it with;
|
||||
/// `resume`: as the client you were there.
|
||||
pub(crate) fn start_join(
|
||||
s: &mut Session,
|
||||
cmd: &str,
|
||||
link: &str,
|
||||
published: Option<Published>,
|
||||
share: Option<Share>,
|
||||
resume: Option<Resume>,
|
||||
) -> Result<SessionId> {
|
||||
let (name, tab) = (display_name(&s.prefs), s.tab());
|
||||
let (id, ()) = s.live.start(cmd, |l, id| l.join(id, link, &name).map_err(|e| bad(cmd, e)))?;
|
||||
let (id, ()) = s.live.start(cmd, |l, id| l.join_resuming(id, link, &name, resume).map_err(|e| bad(cmd, e)))?;
|
||||
let server = share.as_ref().map(|s| s.server.clone()).unwrap_or_default();
|
||||
// The session's file opens in a tab of its own; until then the join shows on this one.
|
||||
s.live.open(Live { id, status: Status::Connecting, tab, server, published, share, ..Live::default() });
|
||||
|
||||
+111
-9
@@ -15,15 +15,16 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_collab::Change;
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Admin, AdminReply, OwnedFile};
|
||||
use figcraft_collab::message::{Admin, AdminReply, OwnedFile, Resume};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::live::{Access, Live, NetReply, NetRequest, Published, Share, Shared, SharedLink, Status, Via, clean_shared};
|
||||
use crate::params::{req_string, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
use crate::{DocState, Result, Session, bad, spec};
|
||||
|
||||
/// Most files remembered: recording another drops the one opened longest ago.
|
||||
pub const MAX_ONLINE_FILES: usize = 200;
|
||||
@@ -92,6 +93,17 @@ pub enum ServerProblem {
|
||||
/// Servers whose files couldn't be asked about at the last refresh, by address.
|
||||
pub type Problems = BTreeMap<String, ServerProblem>;
|
||||
|
||||
/// A recovery copy of one of your online files (`collab.recoverFile`): the file is being joined
|
||||
/// again as session `session`, and the copy waits to be compared with what arrives.
|
||||
pub struct Recovering {
|
||||
pub session: u64,
|
||||
/// The copy, as the unsaved document it opens as when it can't be merged.
|
||||
copy: Box<DocState>,
|
||||
/// Who the app was in the file when the copy was written, and the last change of the server's
|
||||
/// it had then: the copy is that state with the app's own unconfirmed changes on top.
|
||||
at: Resume,
|
||||
}
|
||||
|
||||
/// A name as shown: no control characters, at most 256 characters.
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
|
||||
@@ -253,12 +265,65 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded file document `uid` is open on as its owner, in step with the server:
|
||||
/// `(server, file id)` (what reopening it after a crash takes).
|
||||
pub fn online_of(&self, uid: u64) -> Option<(String, String)> {
|
||||
/// The recorded file document `uid` is open on as its owner and syncs with (connected or
|
||||
/// trying again): `(server, file id)` and who the app is there with the last change of the
|
||||
/// server's it has. A copy of the document made now is that state plus the app's own changes
|
||||
/// the server hasn't confirmed: what recovering it after a crash takes (`collab.recoverFile`).
|
||||
pub fn online_of(&self, uid: u64) -> Option<(String, String, Resume)> {
|
||||
let live = self.live_of(uid);
|
||||
let (server, file) = live.server_file().filter(|_| live.status == Status::Live && live.doc_uid == Some(uid) && live.syncing())?;
|
||||
self.online_index(&server, &file).map(|_| (plain_server(&server), file))
|
||||
let there = matches!(live.status, Status::Live | Status::Reconnecting { .. }) && live.doc_uid == Some(uid);
|
||||
let at = live.client().filter(|_| there)?.resume();
|
||||
let (server, file) = live.server_file()?;
|
||||
self.online_index(&server, &file).map(|_| (plain_server(&server), file, at))
|
||||
}
|
||||
|
||||
/// Settle the recovery copies whose file arrived from its server, or won't (see
|
||||
/// [`recover_file`]). Called when something happened in a session.
|
||||
pub(crate) fn online_recoveries(&mut self) {
|
||||
for r in std::mem::take(&mut self.live.recovering) {
|
||||
match self.live.get(r.session).map(|l| (l.syncing(), l.status.active())) {
|
||||
Some((true, _)) => self.online_merge(r),
|
||||
// Still on its way.
|
||||
Some((false, true)) => self.live.recovering.push(r),
|
||||
// It couldn't be joined, or was left: the copy is what there is.
|
||||
_ => {
|
||||
let name = r.copy.doc.name.clone();
|
||||
self.docs.push(*r.copy);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.message = Some(format!("{name} couldn't be opened from its server: your recovered copy is open, with its unsaved changes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file of recovery copy `r` arrived: nothing to do when the server has everything the
|
||||
/// copy has; the copy's changes go on top as one edit when they are provably only the app's
|
||||
/// own unconfirmed ones (the same client, and nothing changed on the server since the copy);
|
||||
/// otherwise the copy opens beside the file.
|
||||
fn online_merge(&mut self, r: Recovering) {
|
||||
let Some(live) = self.live.get(r.session) else { return };
|
||||
let (Some(client), Some(uid)) = (live.client(), live.doc_uid) else { return };
|
||||
let name = r.copy.doc.name.clone();
|
||||
let me = client.resume();
|
||||
let ahead = figcraft_collab::diff(client.view(), &r.copy.doc).map(|d| d.changes);
|
||||
if ahead.as_ref().is_ok_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
// New layers must be this client's to create (the server refuses anyone else's ids).
|
||||
let own = |changes: &[Change]| changes.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != u64::from(me.client_id)));
|
||||
if me == r.at && client.access() == Access::Edit && ahead.as_deref().is_ok_and(own) {
|
||||
let mut doc = (*r.copy.doc).clone();
|
||||
doc.set_client_id(me.client_id);
|
||||
self.apply_remote(uid, std::sync::Arc::new(doc));
|
||||
self.live_edit(uid, Some("Recovered changes"));
|
||||
self.message = Some(format!("Recovered your changes to {name} that hadn't reached the server"));
|
||||
return;
|
||||
}
|
||||
let mut copy = *r.copy;
|
||||
std::sync::Arc::make_mut(&mut copy.doc).name = format!("{name} (recovered copy)");
|
||||
self.docs.push(copy);
|
||||
self.message =
|
||||
Some(format!("{name} changed on the server before your last changes reached it: they are open beside it as “{name} (recovered copy)”"));
|
||||
}
|
||||
|
||||
/// The session open on recorded file `f`, if one is.
|
||||
@@ -412,6 +477,14 @@ fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
return Ok(brief(id, status, document, true));
|
||||
}
|
||||
let id = join_recorded(s, CMD, i, None)?;
|
||||
Ok(brief(id, "connecting", None, false))
|
||||
}
|
||||
|
||||
/// Join recorded file `i` as its owner for command `cmd` (`resume`: as the client the app was
|
||||
/// there): the new session. The file leads the list.
|
||||
fn join_recorded(s: &mut Session, cmd: &str, i: usize, resume: Option<Resume>) -> Result<u64> {
|
||||
let Some(f) = s.prefs.online_files.get(i).cloned() else { return Err(bad(cmd, "no such file")) };
|
||||
// Yours on the server: its sharing is this session's to start and stop. The token stays in
|
||||
// the record.
|
||||
let published = Published {
|
||||
@@ -424,13 +497,35 @@ fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
expires: f.expires,
|
||||
};
|
||||
let share = Share { via: Via::Online, server: f.server.clone(), access: Access::Edit, links: f.shared.clone() };
|
||||
let id = crate::live::start_join(s, CMD, &f.edit, Some(published), Some(share))?;
|
||||
let id = crate::live::start_join(s, cmd, &f.edit, Some(published), Some(share), resume)?;
|
||||
// Most recently opened first.
|
||||
let mut record = s.prefs.online_files.remove(i);
|
||||
record.opened = now();
|
||||
s.prefs.online_files.insert(0, record);
|
||||
s.online_changed();
|
||||
Ok(brief(id, "connecting", None, false))
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// `collab.recoverFile`: a recovery copy of one of your online files, written while the app was
|
||||
/// the client `session`/`client` in it with the server's changes up to `seq`. The file is joined
|
||||
/// again as its owner (as that client, when the server still knows it); the copy is kept until
|
||||
/// what arrives is compared with it ([`Session::online_recoveries`]), so nothing in it is lost.
|
||||
fn recover_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.recoverFile";
|
||||
let path = req_string(CMD, p, "path")?;
|
||||
let i = named(s, CMD, p)?;
|
||||
let num = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| bad(CMD, format!("`{key}` must be a whole number")));
|
||||
let client_id = u32::try_from(num("client")?).map_err(|_| bad(CMD, "`client` must be a client id"))?;
|
||||
let at = Resume { session: num("session")?, client_id, seq: num("seq")? };
|
||||
if s.prefs.online_files.get(i).is_some_and(|f| s.online_session(f).is_some()) {
|
||||
return Err(bad(CMD, "this file is open already"));
|
||||
}
|
||||
let copy = Box::new(crate::cmd::file::recovered(&path)?);
|
||||
// The whole document is asked for (no change is "after" this number): the app has no state
|
||||
// of its own to catch up from.
|
||||
let session = join_recorded(s, CMD, i, Some(Resume { seq: u64::MAX, ..at }))?;
|
||||
s.live.recovering.push(Recovering { session, copy, at });
|
||||
Ok(json!({"session": session, "status": "connecting"}))
|
||||
}
|
||||
|
||||
fn forget_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -539,5 +634,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
forget_file
|
||||
),
|
||||
spec!(
|
||||
"collab.recoverFile",
|
||||
"Recover online file",
|
||||
"{path (a recovery copy of one of your online files), file, server?, session, client, seq (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “<name> (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which",
|
||||
false,
|
||||
recover_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -335,7 +335,11 @@ fn a_recorded_file_opens_again_as_its_owner_after_a_restart() {
|
||||
(&json!("live"), &json!(true), &json!("NEW-ED1-T00"), &json!(true))
|
||||
);
|
||||
assert!(!peers.to_string().contains(OWNER), "the token stays in the record: {peers}");
|
||||
assert_eq!(s.online_of(s.docs[1].uid), Some((SERVER.to_string(), "f1".to_string())), "what a crash recovery reopens it by");
|
||||
assert_eq!(
|
||||
s.online_of(s.docs[1].uid).map(|(server, file, _)| (server, file)),
|
||||
Some((SERVER.to_string(), "f1".to_string())),
|
||||
"what a crash recovery finds it by"
|
||||
);
|
||||
assert_eq!(s.online_of(s.docs[0].uid), None);
|
||||
assert_eq!(ex(&mut s, "collab.files", json!({}))["files"][0]["document"], 1);
|
||||
// Stop sharing as the owner: the recorded token, the recorded links.
|
||||
|
||||
@@ -1567,6 +1567,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "Online-Datei öffnen"
|
||||
"collab.peers" = "Personen in dieser Datei"
|
||||
"collab.publish" = "Auf Server veröffentlichen"
|
||||
"collab.recoverFile" = "Online-Datei wiederherstellen"
|
||||
"collab.registerLinks" = "Einladungslinks in FigCraft öffnen"
|
||||
"collab.report" = "Datei dem Serverbetreiber melden"
|
||||
"collab.serverInfo" = "Serverinformationen"
|
||||
|
||||
@@ -1568,6 +1568,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "Abrir archivo en línea"
|
||||
"collab.peers" = "Personas en este archivo"
|
||||
"collab.publish" = "Publicar en el servidor"
|
||||
"collab.recoverFile" = "Recuperar archivo en línea"
|
||||
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
|
||||
"collab.report" = "Denunciar archivo al operador del servidor"
|
||||
"collab.serverInfo" = "Información del servidor"
|
||||
|
||||
@@ -1567,6 +1567,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "Ouvrir un fichier en ligne"
|
||||
"collab.peers" = "Personnes dans ce fichier"
|
||||
"collab.publish" = "Publier sur un serveur"
|
||||
"collab.recoverFile" = "Récupérer un fichier en ligne"
|
||||
"collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft"
|
||||
"collab.report" = "Signaler le fichier à l’opérateur du serveur"
|
||||
"collab.serverInfo" = "Informations sur le serveur"
|
||||
|
||||
@@ -1569,6 +1569,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "オンラインファイルを開く"
|
||||
"collab.peers" = "このファイルにいるユーザー"
|
||||
"collab.publish" = "サーバーに公開"
|
||||
"collab.recoverFile" = "オンラインファイルを復元"
|
||||
"collab.registerLinks" = "招待リンクを FigCraft で開く"
|
||||
"collab.report" = "ファイルをサーバーの運営者に報告"
|
||||
"collab.serverInfo" = "サーバー情報"
|
||||
|
||||
@@ -1568,6 +1568,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "온라인 파일 열기"
|
||||
"collab.peers" = "이 파일의 사용자"
|
||||
"collab.publish" = "서버에 게시"
|
||||
"collab.recoverFile" = "온라인 파일 복구"
|
||||
"collab.registerLinks" = "초대 링크를 FigCraft에서 열기"
|
||||
"collab.report" = "서버 운영자에게 파일 신고"
|
||||
"collab.serverInfo" = "서버 정보"
|
||||
|
||||
@@ -1566,6 +1566,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "Abrir arquivo online"
|
||||
"collab.peers" = "Pessoas neste arquivo"
|
||||
"collab.publish" = "Publicar no servidor"
|
||||
"collab.recoverFile" = "Recuperar arquivo online"
|
||||
"collab.registerLinks" = "Abrir links de convite no FigCraft"
|
||||
"collab.report" = "Denunciar arquivo ao operador do servidor"
|
||||
"collab.serverInfo" = "Informações do servidor"
|
||||
|
||||
@@ -1569,6 +1569,7 @@ status = "draft — needs native review"
|
||||
"collab.openFile" = "打开在线文件"
|
||||
"collab.peers" = "此文件中的成员"
|
||||
"collab.publish" = "发布到服务器"
|
||||
"collab.recoverFile" = "恢复在线文件"
|
||||
"collab.registerLinks" = "在 FigCraft 中打开邀请链接"
|
||||
"collab.report" = "向服务器运营者举报文件"
|
||||
"collab.serverInfo" = "服务器信息"
|
||||
|
||||
@@ -83,6 +83,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
|
||||
None => name.clone(),
|
||||
};
|
||||
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
|
||||
crate::a11y::tab(&resp, &label, active);
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
|
||||
|
||||
+20
-3
@@ -579,9 +579,26 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
link in an ordinary `Hello`, so reconnects and sync are a guest's, and the session is marked as
|
||||
the owner's of that server file: `collab.stop` and `collab.share` there stop and start its
|
||||
sharing with the recorded token, exactly as in the session that published it. A file that is
|
||||
open already comes to the front instead. After a crash, the desktop app reopens a recorded file
|
||||
that was open and in step with its server this way instead of from its recovery copy (the copy
|
||||
is kept only as a fallback when the record is gone).
|
||||
open already comes to the front instead.
|
||||
- **After a crash, nothing is lost** (`collab.recoverFile`, the desktop app's recovery). The
|
||||
recovery copy of a recorded file has a note written after it: the server, the file, and the
|
||||
app's `resume` there when the copy was made (session, client id, the last sequence number it
|
||||
had); so the copy is that confirmed state plus the app's own unconfirmed changes. The next start
|
||||
joins the file as its owner with that `resume`, its `seq` set past any real one so the welcome
|
||||
carries the whole document, and keeps the copy until the file arrives. A server that still
|
||||
knows the client gives it its id back, and the new sync client numbers its batches after the
|
||||
last one the server processed for that id (`Welcome.processed`), so they aren't taken for
|
||||
repeats. Then the copy is compared with the server's document (the same diff an edit syncs as):
|
||||
nothing differs: the copy goes; it differs, the app is the same client and the server's
|
||||
sequence number is the one in the note (nothing else changed there, so the difference is exactly
|
||||
what never arrived): it is applied on top as one edit, "Recovered changes", sent to everyone and
|
||||
undoable; anything else (others changed the file meanwhile, the server no longer knows the
|
||||
client and would refuse its layer ids, a view link): the copy opens beside the file as
|
||||
"<name> (recovered copy)", untouched, and a message says so. A file that can't be joined
|
||||
(offline, refused) opens its copy as an unsaved document, as any recovery copy; the record
|
||||
stays. The old copy is removed only after the outcome, once the new run has made its own copy
|
||||
of what it became. Recovery copies are written at most every ten seconds, so the last seconds
|
||||
before a crash are covered only when the server confirmed them.
|
||||
- **What became of them** (`collab.files {refresh: true}`): one `OpenHosting` connection per
|
||||
server that names no file, then one `Admin owned` with every file recorded on that server.
|
||||
A file the server answers `null` for shows as gone (removed or expired) and can be cleared; a
|
||||
|
||||
@@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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. `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 | 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 |
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
|
||||
@@ -995,6 +995,12 @@ only: a host stops hosting it, a guest leaves it.
|
||||
the list **on this computer only**: the file stays on its server (`onServer`) until it expires,
|
||||
and can't be managed from here again. To take it off the server use `collab.manageFile {file,
|
||||
action: "remove"}` instead.
|
||||
- `collab.recoverFile {path, file, server?, session, client, seq}` is what the desktop app's crash
|
||||
recovery runs for the copy of an online file (the last three come from the note beside the
|
||||
copy): it joins the file as its owner and keeps the copy until the file arrives, then discards
|
||||
it (the server has everything), applies its unconfirmed changes on top as one synced edit, or
|
||||
opens it beside the file as "<name> (recovered copy)"; a file that can't be joined opens the
|
||||
copy as an unsaved document. Returns `{session, status: "connecting"}`.
|
||||
- `collab.manageFile {file, action, server?, owner?, …}` manages a file published without the key,
|
||||
with its owner token. `server` and `owner` default to what is recorded for one of your online
|
||||
files (`collab.files`); give both for a file that isn't recorded here. `links` → `{links: [{id,
|
||||
|
||||
+6
-2
@@ -142,8 +142,12 @@ screen lists them under **Online**:
|
||||
others get into (a file you share or joined), a closed ring for a file of yours on a server that
|
||||
only you get into now. Messages about a file that isn't in front name it ("Ada joined
|
||||
Homepage").
|
||||
- **After a crash**, a file of yours that was open and in step with its server opens again from
|
||||
the server as its owner, rather than as an unsaved recovery copy.
|
||||
- **After a crash**, a file of yours that was open opens again from the server as its owner, and
|
||||
the recovery copy of it is kept until it is known that nothing in it is missing there: changes
|
||||
of yours that never reached the server (it was unreachable, or the app died first) are put back
|
||||
on top and sent to everyone ("Recovered your changes to …"); if others changed the file in the
|
||||
meantime the copy opens beside it as "… (recovered copy)" for you to compare; if the server
|
||||
can't be reached the copy opens as an unsaved document, as before.
|
||||
|
||||
The owner token is a password for that one file: it is kept only in the preferences file of your
|
||||
user account (`state.json` in FigCraft's settings folder), is never logged, and never appears in
|
||||
|
||||
Reference in New Issue
Block a user