craft-cloud-kit makes a Craft app's documents live: the same file open on two machines (or in two apps on one)
shows every edit on the other as it is made, works offline, and never makes conflict copies of its own saves. The
app's whole change is one adapter. Everything else (talking to the Craft Cloud agent, the Loro document,
reconnecting, binary content, undo while live) is in the kit. craft-cloud-egui adds Craft Cloud's piece of the
window: the account line, the shared recent files and the document's badge.
The demo app in this repository (apps/craft-cloud-kit-demo, never packaged) is a complete example: a rich-text
story and a layer tree; its adapter is about 150 lines of model.rs, its hook one function of ui.rs. The
snippets below are from it.
[dependencies]
craft-cloud-kit = { git = "https://github.com/itrycodinganditsbad/craft-cloud", tag = "v0.1.0" }
craft-cloud-egui = { git = "https://github.com/itrycodinganditsbad/craft-cloud", tag = "v0.1.0" }
[dev-dependencies]
# The fake agent, for the adapter's tests.
craft-cloud-kit = { git = "https://github.com/itrycodinganditsbad/craft-cloud", tag = "v0.1.0", features = ["fake"] }The kit brings Loro (and Loro's own crates) and nothing a Craft app doesn't already lock: cargo xtask suite-deps <the apps' Cargo.lock files> checks it. There is no async runtime: the kit is blocking where it waits (opening) and
never blocks where it is called each frame.
The app implements [LiveDocument] for its model, the Arc-shared document its commands already clone
copy-on-write:
impl LiveDocument for DemoApp {
type Doc = DemoDoc;
const APP: &'static str = "kitdemo"; // as in recents
const SCHEMA: u32 = 1; // the layout the mappings write
fn mappings(doc: &DemoDoc) -> Mappings<'_> {
Mappings::new().tree("layers", LayersSrc::new(&doc.layers)).text("story", StorySrc(&doc.story))
}
fn apply_remote(old: &Arc<DemoDoc>, patch: &RemotePatch<'_>) -> Result<Arc<DemoDoc>, KitError> {
let mut new = (**old).clone();
if let Some(tree) = patch.tree("layers") {
new.layers = Arc::new(build(&tree, tree.roots())); // or only the touched nodes
}
if let Some(story) = patch.text("story") {
new.story = Arc::new(story);
}
Ok(Arc::new(new))
}
fn normalise(doc: &mut DemoDoc) { /* name a layer two machines both cleared */ }
fn refresh_derived(doc: &mut DemoDoc) { doc.paragraphs = doc.story.paragraphs.len(); }
}mappingsnames the parts of the model the live document holds, each by a key, each a shape: a tree ([TreeSource]: layers, objects, bins), rich text ([TextSource]: a story; [RichText] is one), a table ([TableSource]: rows, columns and cells; one column makes keyed records), a timeline ([TimelineSource]: tracks, items, keyframes) or an opaque value ([OpaqueSource], last writer wins; [Opaque] wraps an [OpaqueCodec]). Everything else (view state, selection, caches) stays the app's own.- The sources are the app's own types read in place. Give every source its
key(theArc's address) and the tree itssubtree_key: an unchanged part or subtree is skipped without being compared, so a keystroke in a 1 MB story costs well under a millisecond with 5,000 layers beside it. A table can say which cells changed (changed_cells). - IDs come from the app (
ElemId, au128): the kit never makes them from positions. A model that addresses by index (paragraphs, table cells) adds IDs first;ElemId::new()makes a random one. apply_remotegets the model before a remote change and what it touched, part by part (None: untouched, keep it). Rebuild only that: the views say what changed (touch()), and read the part as it is now. A rich text comes whole, patched from the old one.normaliseis required: it repairs what a merge can break (two layers with one name, a cell whose row went, a reference to a deleted element), so the result is a model the app could have made itself. The kit writes back what it changed (no undo step). It runs after every remote change, thenrefresh_derivedrecomputes what is derived and never synced, before the app gets the model. Every machine normalises the same merge and writes its repair, so a repair must be deterministic: the same model in, the same repair out, with any new element's ID and value derived from what it repairs (neverElemId::new()or the clock), so two machines' repairs are one.- Property values ([
Value]) are numbers, strings, bytes and chunk references, at most 256 KiB each. Binary content never goes into the document: put it in a [BlobStore] ([AgentBlobs] is the agent's) and keep the [ChunkRef].
match Session::<DemoApp>::open(&Agent::local(), "Notes/story.kitdemo", file_model, Some(&file_bytes))? {
Open::Live(session) => { /* the model is session.model() */ }
Open::NotLive(why) => { /* a plain file: open and save it as always, with the app's own undo */ }
}The path is relative to the synced folder. A file that has never been live is imported from the app's model (the
one it read from the file); otherwise the model is built from the live document, which may be newer than the file.
Opening waits for the agent (up to 30 s, when it must sync the file first): call it where the app opens files, or on
a thread (a Session is Send). A document made by a newer version of the app opens read-only
(status().read_only: show "update the app"); local changes and undo are refused.
At the end of each top-level command, with the model before and after it:
if !Arc::ptr_eq(&before, &self.doc) {
self.session.local_change(&before, &self.doc)?; // into Loro, out through the agent; one undo step
}beforemust be the session's model ([KitError::Stale] otherwise: a remote change came in between; make the change again fromsession.model()).- A change is cut into commits under 512 KiB (the agent takes updates of up to 1 MiB): a 5 MB paste goes, and undoes, whole.
- A gesture (a drag, an IME composition) whose
beforemust stay put until it ends:session.hold_remote(true)while it runs; remote changes wait and come at the first poll after. - Typing:
session.group(|s| …)(orbegin_group/end_group) makes several changes one undo step.
let polled = self.session.poll(); // never blocks
if polled.changed { self.doc = self.session.model().clone(); }
craft_cloud_egui::badge(ui, &polled.status); // Live, Waiting 3, Read-only, Offlinesession.set_waker(Arc::new(move || ctx.request_repaint())) wakes the window when something comes from the agent.
When the agent goes away the model stays editable: changes queue in memory (at most 64 MiB; past that
[KitError::Stalled]) and go out once the session is back, which it finds by itself with a growing pause, off the
UI thread (reconnect_now() skips the pause).
While a document is live, undo and redo go through the session: session.undo(), session.redo(),
can_undo(). They take back only this machine's changes (another machine's edit to the same text stays), and the
result comes back to the model as a remote change does (take session.model()). The app's own snapshot undo would
silently revert other people's edits: use it only while the document isn't live. session.undo_kind() says which
applies.
Save and autosave work as before, in this order:
// 1. Where the save starts: the model to write and the live document's frontiers it matches.
let point = self.session.save_point();
// 2. The bytes, from that model (or, with no save point, from the app's own model: no record then).
let bytes = to_file(point.as_ref().map_or(&self.model, |p| p.model()));
// 3. The record (content hash and frontiers) to the agent, before the file changes on disk.
if let Ok(point) = &point {
self.session.record(point, &bytes);
}
// 4. The write.
write_atomic(&path, &bytes)?;save_point() refuses (KitError::NoRecord) when a record would be wrong: the document is read-only here (a newer
app's), the session is stalled or stopped, or the model is behind the live document. The app saves its own model
then, and the file syncs as any file does.
A record names the live document's frontiers (its heads, one or two operation IDs however many machines and sessions
edited it), never a version vector. record first announces the save to the agent at once (its content's hash),
before the write: until the record itself comes, the agent's sync takes that content for a Craft save on its way, and
neither uploads it without its record nor makes a conflict copy of it, but waits. A save made while the app has no
agent connection (reconnecting, the agent restarting) can't be announced; the agent waits for those too, for any
change to a file whose document an app had open within the last 60 s (it keeps that on disk), and the session
announces and records them when it is back. Each content is waited for 10 minutes in all; a record that never comes
leaves the version as made outside Craft, as before. (The announcement isn't acknowledged: an agent stalled for longer
than its sync's settle window before it reads the announcement can still miss it.) The session sends the record on the same stream as its changes, after every
change it covers; the agent keeps it once all those operations are in its copy (a record that comes first waits for them, and
is dropped if the window closes before they come: that version then has no record, and its edits are in the file, so
nothing is lost). The record goes before the write so the agent knows the content before its sync pass sees it; while
the agent is away the session keeps the record and tells it once back, and the agent adds it to the version it
uploaded meanwhile (a server call of its own, appended once). Every session writes with a fresh random Loro peer of
its own; nothing is ever written with a peer another session may have had.
The agent records it with the version it uploads, so two machines saving one live file never make a conflict copy: the save that includes the other's wins, or else the newer upload. Which includes which the agent asks its own copy of the document; while it can't tell (it lacks the operations), neither side is taken and the next pass asks again, and after 10 minutes still unknown it is a conflict copy, as for any two edits. A winner is never guessed. The losing version is no conflict copy, but it stays in the server's history, and nothing of it is lost: the live document has both sides' edits.
The live document is the truth and the file a copy of it, which may be late. To have it catch up on its own,
autosave when the model is dirty or when session.file_behind() says the live document is strictly ahead of the
file (another machine's edits came live, or this machine's after another machine's save won over this one's):
if autosave_due && (self.dirty || self.session.file_behind()) {
self.save(); // the order above
}The session knows the record of the file as it is on disk from the agent (it looks at the file of every open
document each second), and from its own last save. file_behind() is true only when everything the file was saved
from is in the live document and the live document has more. When the file has anything this live document lacks
(another machine's save that came by file sync before its live updates, a save made apart), it is false: this side
waits for the live updates, so a file ahead of the live document is never written over by itself, and two machines
whose live connection is down while files still sync never save back and forth. It is false too when no record of the
file is known (an edit outside Craft; an older agent), when a save couldn't carry a record, and for 5 s after a save
it asked for (a backstop). Once every window's file has the live document's frontiers, nobody saves again. When no
app has the document open, nobody writes the file: it stays behind until the next open, which shows the live document
and saves when it is ahead.
A file edited outside Craft has no record: it gets a conflict copy as always, and the kit says so on open
(status().changed_outside). It is decided once, at the open, from the bytes the app read (Session::open's
file_bytes): the agent looks their hash up among this machine's records and the record of the file's version on the
server, so any Craft save, however many came after it, is never taken for an outside edit. A Craft save made without
a record (read-only, stopped, a record that never reached the agent) reads as changed outside too; its edits are in
the file.
Bringing the file in is the user's choice, never the kit's: the app shows changed_outside and offers it, and on a
yes calls session.replace_with_file(file_model). The file's content then replaces the live content as one ordinary
change on every machine, one undo step named "Replace with the file's content" (step_name, for the Undo label). Say
plainly in the prompt that it replaces the live edits made since the file was written, and that Undo brings them
back. Saving clears changed_outside.
session.set_presence(Some(bytes)) shows this app's cursor or selection to the others (the bytes are the app's,
at most 4 KiB less 8); session.peers() is theirs, dropped 10 s after they go quiet. Drawing them is the app's.
craft_cloud_egui::CloudWatch::start(app, connect, wake) keeps the agent's state and the app's recents current off
the UI thread; account_line(ui, &watch.state()) and recents(ui, watch.entries(), now) draw them (a click returns
the path); watch.add_recent(path, saved) puts a file on every machine's list.
With the fake feature, craft_cloud_kit::testing::{Hub, Fake} is an agent in the test's own process: machines of
one account on a shared hub, with set_online, restart and start, drop_connections, and an old-agent mode.
Two sessions on two fakes test merges, undo and reconnects with no server; the demo's tests drive two windows that
way with kittest.
- Loro's undo of one big run of inserted text takes time growing with its square (about 10 s for a 1 MB paste). Undo of ordinary edits is instant.
- A remote change to a rich text is patched into the old text; a tree's structure change reads the tree's structure again (cheap per node, 5,000 nodes in a few milliseconds); a table or timeline is read back by the app as much as it wants.