mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 06:04:25 +00:00
* M9: real-time collaboration protocol (design) docs/collab-protocol.md: WebSocket sessions with a server-ordered, per-property last-writer-wins document sync, client-scoped ids, fractional positions for children, ephemeral presence (cursors, selections, follow), per-user undo, hosting from the app first and a self-hosted server next, and the implementation plan. ROADMAP gains M9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: collaboration core: ids, change sets, server ordering, convergence Step 1 of docs/collab-protocol.md, no networking: - Client-scoped node ids: Document::set_client_id makes alloc_id hand out client << 32 | counter; single-user files allocate as before; the id counter is no longer part of document equality. - crates/collab (layer 2): the synced object/property model (every saved Node field, plus document-level styles, comments, variables, images, dev snapshots, library data), Change batches, an O(changed) diff, strict and lenient apply, fractional sibling positions, snapshots. - Server (ordering, validation, rejects with fixes) and Client (optimistic view, replay under remote batches, author-side repair of derived data) as pure state machines; binary (version byte + postcard, JSON values) and JSON message forms, fuzzed decoders. - Engine: normalize as the recompute hook, collab.diff. - Tests: round trips over random real commands, an N-client convergence fuzz test over delayed in-order links, junk batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: fmt after merge * M9: live sessions: host from the app, invite links, live cursors and presence Step 2 of docs/collab-protocol.md, networking and presence half: - crates/collab-net (layer 6): WebSocket transport (sync tungstenite, a reader and a writer thread per connection, no async runtime), the session host the app embeds (runs figcraft_collab::Server as the authority, carries its messages), invite links, presence throttling (~30 Hz, latest wins), limits (sockets, hello/idle timeouts, message size, rate, slow clients), keep-alive, clean close, reconnect with backoff; a browser stand-in until the web-sys client (step 4). - engine: live session state (status, peers, presence, follow) and the collab.host/stop/join/leave/peers/follow/cursor commands; the network is reached only through the LiveLink the app installs. - ui-egui: remote cursors (pointer drawn in code, name tag, gliding), selection outlines (once documents sync), avatars in the right panel with follow, the Share popover (start, invite link + URL with Copy, stop), File > Join live session. - desktop app and MCP headless session install the link; xtask layers keeps networking crates out of L0-L5. - Documents don't sync between apps yet (follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: live sessions: document sync between apps - The host's session runs figcraft_collab::Server seeded with the shared document; every app (the host's too, through the hub without a socket) is a collab Client. Guests open the host's file from the welcome as a new tab; leaving keeps it as a local copy. - Engine: after each local action (a gesture once it ends; nothing during version or text previews) the session document is diffed into one Changes batch; server batches go through Client::receive with EngineRecompute and are installed with Session::apply_remote, which never touches the undo history. Batches arriving mid-gesture wait. - Transport: Changes/Reject/Welcome reach the engine as Event::Sync; LiveLink::send carries batches in order; hellos are limited to 64 KiB, joined clients to the core's message size. - Tests: engine sync against a real Server (ids in the client's space, one batch per gesture, remote edits skip undo, deferral, closing the file leaves), three apps over loopback converging (create, move, recolour, delete, rename) and a late joiner getting the server's state. - Docs: collab-protocol, mcp, README, roadmap docs; the UI's "cursors only" note is gone, replaced by the per-person undo caveat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
9.9 KiB
Rust
264 lines
9.9 KiB
Rust
//! Project tasks: `cargo xtask <task>`.
|
|
//!
|
|
//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check
|
|
//! - `layers` crate dependency layering check
|
|
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
|
|
//! - `parity` menu-catalog coverage → docs/parity-checklist.md
|
|
//! - `version` print the version, or `version set X.Y.Z`
|
|
//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork)
|
|
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
|
|
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
|
|
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
|
|
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
|
|
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
|
|
//! with a synthetic design system used by 2,000 instances
|
|
|
|
mod icon;
|
|
mod layers;
|
|
|
|
use std::process::{Command, ExitCode};
|
|
|
|
fn run(cmd: &mut Command) -> bool {
|
|
eprintln!("$ {cmd:?}");
|
|
cmd.status().map(|s| s.success()).unwrap_or(false)
|
|
}
|
|
|
|
fn cargo() -> Command {
|
|
Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
|
}
|
|
|
|
fn root() -> std::path::PathBuf {
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(|p| p.to_path_buf()).unwrap_or_default()
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let task = args.first().map(String::as_str).unwrap_or("help");
|
|
let ok = match task {
|
|
"ci" => ci(),
|
|
"layers" => layers::check(&root()),
|
|
"assets" => assets(),
|
|
"parity" => parity(),
|
|
"version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)),
|
|
"icon" => icon::generate(&root()),
|
|
"wasm" => wasm(),
|
|
"plugins" => plugins(),
|
|
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
|
|
_ => {
|
|
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
|
|
task == "help"
|
|
}
|
|
};
|
|
if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
|
}
|
|
|
|
fn ci() -> bool {
|
|
run(cargo().args(["fmt", "--all", "--check"]))
|
|
&& run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))
|
|
&& run(cargo().args(["test", "--workspace"]))
|
|
&& layers::check(&root())
|
|
&& assets()
|
|
&& wasm()
|
|
}
|
|
|
|
fn wasm() -> bool {
|
|
run(cargo().args([
|
|
"check",
|
|
"--target",
|
|
"wasm32-unknown-unknown",
|
|
"-p",
|
|
"figcraft-collab",
|
|
"-p",
|
|
"figcraft-collab-net",
|
|
"-p",
|
|
"figcraft-engine",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"-p",
|
|
"figcraft-web",
|
|
])) && plugins()
|
|
}
|
|
|
|
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
|
/// `<name with _>.wasm`).
|
|
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid"];
|
|
|
|
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
|
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
|
fn plugins() -> bool {
|
|
let root = root();
|
|
let manifest = root.join("plugins").join("Cargo.toml");
|
|
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("plugins");
|
|
let ws = |args: &[&str]| {
|
|
let mut c = cargo();
|
|
c.args(args).arg("--manifest-path").arg(&manifest);
|
|
c
|
|
};
|
|
let wasm = ["--target", "wasm32-unknown-unknown", "--locked"];
|
|
let ok = run(&mut ws(&["fmt", "--all", "--check"]))
|
|
&& run(ws(&["clippy", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target).args(["--", "-D", "warnings"]))
|
|
&& run(ws(&["test", "--workspace", "--locked"]).arg("--target-dir").arg(&target))
|
|
&& run(ws(&["build", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target));
|
|
if !ok {
|
|
return false;
|
|
}
|
|
for name in EXAMPLE_PLUGINS {
|
|
let module = format!("{}.wasm", name.replace('-', "_"));
|
|
let dist = target.join("dist").join(name);
|
|
let copied = std::fs::create_dir_all(&dist)
|
|
.and_then(|()| std::fs::copy(root.join("plugins/examples").join(name).join("figcraft-plugin.json"), dist.join("figcraft-plugin.json")))
|
|
.and_then(|_| std::fs::copy(target.join("wasm32-unknown-unknown/release").join(&module), dist.join(&module)));
|
|
match copied {
|
|
Ok(bytes) => eprintln!("plugins: {} ({} KB)", dist.display(), bytes / 1024),
|
|
Err(e) => {
|
|
eprintln!("plugins: {name}: {e}");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the
|
|
/// whole UI headless), release builds, for each comma-separated document size in layers.
|
|
fn bench(sizes: &str) -> bool {
|
|
run(cargo().args(["run", "--release", "-p", "figcraft-cli", "--", "perf", "--layers", sizes]))
|
|
&& run(cargo().env("FIGCRAFT_BENCH_LAYERS", sizes).args([
|
|
"test",
|
|
"--release",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"--lib",
|
|
"perf_ui_frames",
|
|
"--",
|
|
"--ignored",
|
|
"--nocapture",
|
|
]))
|
|
}
|
|
|
|
/// Every asset file must be listed in ATTRIBUTION.md.
|
|
fn assets() -> bool {
|
|
let root = root();
|
|
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).unwrap_or_default();
|
|
let mut missing = Vec::new();
|
|
for dir in ["assets", "docs/brand", "docs/images"] {
|
|
walk(&root.join(dir), &mut |p| {
|
|
let rel = p.strip_prefix(&root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
|
let name = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
|
if name.starts_with('.') || name.ends_with(".md") || name.ends_with(".txt") {
|
|
return;
|
|
}
|
|
// Listed by path, or by any ancestor directory written as `dir/`.
|
|
let mut listed = attribution.contains(&format!("`{rel}`"));
|
|
let mut cur = rel.as_str();
|
|
while let Some((parent, _)) = cur.rsplit_once('/') {
|
|
if attribution.contains(&format!("`{parent}/`")) {
|
|
listed = true;
|
|
break;
|
|
}
|
|
cur = parent;
|
|
}
|
|
if !listed {
|
|
missing.push(rel);
|
|
}
|
|
});
|
|
}
|
|
if !missing.is_empty() {
|
|
eprintln!("assets without an ATTRIBUTION.md row:\n {}", missing.join("\n "));
|
|
return false;
|
|
}
|
|
eprintln!("assets: ok");
|
|
true
|
|
}
|
|
|
|
fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
walk(&p, f);
|
|
} else {
|
|
f(&p);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parity() -> bool {
|
|
let extra = [
|
|
"file.open",
|
|
"file.import",
|
|
"file.placeImage",
|
|
"file.save",
|
|
"file.saveAs",
|
|
"file.export",
|
|
"file.exportSvg",
|
|
"edit.copyAsText",
|
|
"edit.copyAsCss",
|
|
"edit.copyAsSvg",
|
|
"edit.copyAsPng",
|
|
"edit.copyLink",
|
|
];
|
|
let (done, total, missing) = figcraft_engine::catalog::parity(&extra);
|
|
let pct = done as f64 * 100.0 / total.max(1) as f64;
|
|
// The generation date: the date of the last commit (no clock dependency in xtask).
|
|
let date = Command::new("git")
|
|
.args(["log", "-1", "--format=%cs"])
|
|
.current_dir(root())
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.map(|d| d.trim().to_string())
|
|
.unwrap_or_default();
|
|
let mut md = format!(
|
|
"# Parity checklist\n\n> **Generated:** {date} by `cargo xtask parity`. Do not edit by hand.\n\nFigCraft commands registered for the incumbent's main menu and toolbar ({} commands in the engine).\n\n**{done} / {total} menu items ({pct:.1}%)**\n\n",
|
|
figcraft_engine::all_commands().len()
|
|
);
|
|
if missing.is_empty() {
|
|
md.push_str("Every catalog item has a command.\n");
|
|
} else {
|
|
md.push_str("## Missing\n\n");
|
|
for m in &missing {
|
|
md.push_str(&format!("- `{m}`\n"));
|
|
}
|
|
}
|
|
md.push_str("\nMenu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity.\n");
|
|
let path = root().join("docs/parity-checklist.md");
|
|
if let Err(e) = std::fs::write(&path, md) {
|
|
eprintln!("{}: {e}", path.display());
|
|
return false;
|
|
}
|
|
eprintln!("parity: {done}/{total} ({pct:.1}%) → docs/parity-checklist.md");
|
|
true
|
|
}
|
|
|
|
fn version(sub: Option<&str>, value: Option<&str>) -> bool {
|
|
let path = root().join("Cargo.toml");
|
|
let Ok(text) = std::fs::read_to_string(&path) else { return false };
|
|
let cur = text
|
|
.lines()
|
|
.skip_while(|l| !l.starts_with("[workspace.package]"))
|
|
.find_map(|l| l.strip_prefix("version = \"").and_then(|r| r.strip_suffix('"')))
|
|
.unwrap_or("0.0.0")
|
|
.to_string();
|
|
match (sub, value) {
|
|
(Some("set"), Some(v)) => {
|
|
let valid =
|
|
v.split(['-', '+']).next().is_some_and(|core| core.split('.').count() == 3 && core.split('.').all(|p| p.parse::<u64>().is_ok()));
|
|
if !valid {
|
|
eprintln!("version must look like 1.2.3 or 1.2.3-rc.1");
|
|
return false;
|
|
}
|
|
let new = text.replacen(&format!("version = \"{cur}\""), &format!("version = \"{v}\""), 1);
|
|
if std::fs::write(&path, new).is_err() {
|
|
return false;
|
|
}
|
|
eprintln!("version {cur} → {v}");
|
|
run(cargo().args(["update", "--workspace"]))
|
|
}
|
|
_ => {
|
|
println!("{cur}");
|
|
true
|
|
}
|
|
}
|
|
}
|