- Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path). Rectangles with smooth (squircle) corners, polygons, stars, and arcs and rings shaped with on-canvas sweep, start and ratio handles.
+ Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes, strokes and open paths to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude) whose operands stay editable on the canvas with the result following every drag, an operation per operand, flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path, one side at a time with Alt). Rectangles with smooth (squircle) corners, polygons, stars, and arcs and rings shaped with on-canvas sweep, start and ratio handles or the Design panel's Arc fields.
Paints and effects
@@ -139,7 +139,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
-- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow (undo is not per person yet). Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
+- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
+- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
@@ -149,6 +150,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
git clone https://github.com/storytold/figcraft
cd figcraft
cargo run --release -p figcraft # the desktop app
+cargo run --release -p figcraft-server # the collaboration server (docs/server.md)
cargo test --workspace # the test suite
cargo xtask ci # fmt, clippy, tests, layering and asset checks
```
@@ -166,7 +168,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
-> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app on a local network, with per-person undo; no sharing or permissions yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
+> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## The Crafting Apps
diff --git a/ROADMAP.md b/ROADMAP.md
index dcd6572..e3fd38e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -14,19 +14,19 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **98.5%** (201 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
-| Feature breadth | **~78%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
+| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
-| Mainstream practitioner | **~43%** | estimated: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
+| Mainstream practitioner | **~43%** | estimated: weekly areas 66.7% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~200–365 h** one agent | estimated |
-| Remaining to full parity | **~390–745 h** one agent (~95–170 h wall-clock with 5 agents) | estimated |
+| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
-| Full target (ready for real work) | ~47% | ~375–705 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
-| Mainstream practitioner | ~43% | ~180–330 h | core editor depth incl. `.fig` import (95–184), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
+| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
+| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (88–173), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@@ -45,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
-FigCraft can't open Figma's own `.fig` files, has no multiplayer, and nothing has been checked
+FigCraft can't open Figma's own `.fig` files, has only a preview of multiplayer, and nothing has been checked
against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is missing, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` reader (30–60 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
@@ -56,10 +56,10 @@ the localization layer with Figma's own languages (20–35 h).
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
-| Features (depth) | 57% (core editor 67%) | 75–142 | [target-app-parity](docs/target-app-parity.md) |
+| Features (depth) | 57% (core editor 67%) | 68–131 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
-| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
+| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) |
@@ -75,7 +75,7 @@ the localization layer with Figma's own languages (20–35 h).
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Selection & transform | 95% | 68% | 6–10 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
-| Shapes & vector | 92% | 66% | 8–14 | [target-app-parity](docs/target-app-parity.md) |
+| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 90% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
@@ -123,10 +123,15 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h.
-5. Multiplayer (M9): sharing and a standalone server with TLS on top of the live sessions (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, and the browser client landed): 45–90 h.
+5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view landed): 45–90 h.
## Progress log
+- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click
+ selects one, moving, resizing or editing its points redraws the result during the drag with every
+ operand outlined, per-operand operations from the boolean controls, prefix-cached recompute);
+ Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths
+ (gap 13). Feature breadth ~79%.
- **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu,
Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without
networking (#103: client-scoped ids, change sets, server ordering and validation, client
@@ -153,6 +158,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
+| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
+| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs
index 4e90403..1bbee2a 100644
--- a/apps/figcraft-cli/src/perf.rs
+++ b/apps/figcraft-cli/src/perf.rs
@@ -158,6 +158,7 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy)
};
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0);
+ measure_boolean_drag(table, col);
let mut s = Session::with_document(doc);
let doc = s.doc().map_err(failed)?.clone();
@@ -240,6 +241,41 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
Ok(())
}
+/// Live boolean editing: one drag frame of an operand of a boolean group of 24 overlapping
+/// circles, i.e. the group's result recomputed with the operand somewhere new (each frame a new
+/// position, as in a real drag). The top operand recombines one step; the bottom one, all 23.
+fn measure_boolean_drag(table: &mut Table, col: usize) {
+ let mut d = Document::new("booleans");
+ let Some(page) = d.first_page() else { return };
+ let Ok(b) = d.create(page, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Union }, "Union", 0.0, 0.0, 0.0, 0.0) else { return };
+ let kids: Vec = (0..24)
+ .filter_map(|i| {
+ let a = f64::from(i) * std::f64::consts::TAU / 24.0;
+ d.create(
+ b,
+ NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 },
+ "Circle",
+ 200.0 + 120.0 * a.cos(),
+ 200.0 + 120.0 * a.sin(),
+ 90.0,
+ 90.0,
+ )
+ .ok()
+ })
+ .collect();
+ let _ = d.fit_to_children(b);
+ let frame = |which: usize, k: usize| {
+ let mut moved = d.clone();
+ if let Some(n) = kids.get(which).and_then(|id| moved.node_mut(*id)) {
+ // Distinct offsets per run and column, so no frame is served from the cache.
+ n.transform = figcraft_doc::geom::Affine::translate((0.37 * (k + 1) as f64 + 1000.0 * col as f64, 0.11 * k as f64)) * n.transform;
+ }
+ moved.node(b).and_then(|n| figcraft_render::node_outline(&moved, n)).is_some()
+ };
+ table.put(col, "boolean of 24: drag frame, top operand", mean(20, |k| _ = frame(23, k)));
+ table.put(col, "boolean of 24: drag frame, bottom operand", mean(5, |k| _ = frame(0, 100 + k)));
+}
+
/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design
/// system, for each instance count in `instances`; print the tables.
pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult {
diff --git a/apps/figcraft-server/Cargo.toml b/apps/figcraft-server/Cargo.toml
new file mode 100644
index 0000000..bf3877f
--- /dev/null
+++ b/apps/figcraft-server/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "figcraft-server"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+description = "FigCraft's standalone collaboration server: keeps many files with share links (edit or view), persists them as snapshots plus a log, and serves live sessions (headless, native only)"
+
+[lib]
+path = "src/lib.rs"
+
+[[bin]]
+name = "figcraft-server"
+path = "src/main.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+figcraft-collab = { workspace = true }
+figcraft-collab-net = { workspace = true }
+log = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+crc32fast = { workspace = true }
+toml = "1"
+env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
+
+[dev-dependencies]
+figcraft-engine = { workspace = true }
+figcraft-doc = { workspace = true }
+tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs
new file mode 100644
index 0000000..95ebdc1
--- /dev/null
+++ b/apps/figcraft-server/src/cli.rs
@@ -0,0 +1,294 @@
+//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
+//! directory. When a server is running on the directory they ask it (so revoking a link
+//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
+
+use std::fmt::Write as _;
+use std::net::{IpAddr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
+use figcraft_collab_net::hub::Backend;
+use figcraft_collab_net::{Endpoint, Invite, manage};
+use serde::{Deserialize, Serialize};
+
+use crate::config::Config;
+use crate::disk;
+use crate::store::{self, KEY, Store};
+
+/// Where a running server says it listens.
+pub const SERVER_JSON: &str = "server.json";
+
+pub const USAGE: &str = "\
+figcraft-server: FigCraft's collaboration server (files, share links, live sessions)
+
+Usage:
+ figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
+ figcraft-server key [--rotate] the address to publish to, with the server key
+ figcraft-server files [list] the files kept
+ figcraft-server files remove FILE remove a file and its links for good
+ figcraft-server links list FILE a file's share links
+ figcraft-server links create FILE --access edit|view [--label TEXT]
+ figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
+
+Options (also keys of the --config TOML file):
+ --listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
+ --data DIR the data directory (default ./figcraft-data)
+ --public-url URL how links name the server behind a reverse proxy (wss://host/path/)
+ --config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
+ max_connections, max_people, compact_after, compact_mb
+ -h, --help this text
+ -V, --version the version
+
+Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
+";
+
+/// What to do.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Command {
+ Serve,
+ Key { rotate: bool },
+ Files,
+ RemoveFile { file: String },
+ Links { file: String },
+ CreateLink { file: String, access: Access, label: String },
+ Revoke { file: String, link: String },
+ Help,
+ Version,
+}
+
+/// A parsed command line.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Cli {
+ pub config: Config,
+ pub command: Command,
+}
+
+/// Read the arguments (after the program name).
+pub fn parse(args: &[String]) -> Result {
+ // The config file first: flags override it.
+ let mut config = match args.iter().position(|a| a == "--config") {
+ Some(i) => Config::load(Path::new(args.get(i + 1).ok_or("--config needs a file")?))?,
+ None => Config::default(),
+ };
+ let mut words = Vec::new();
+ let mut access = None;
+ let mut label = String::new();
+ let mut rotate = false;
+ let mut it = args.iter();
+ while let Some(a) = it.next() {
+ let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
+ match a.as_str() {
+ "-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
+ "-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
+ "--config" => {
+ value("--config")?;
+ }
+ "--listen" => config.listen = value("--listen")?,
+ "--data" => config.data = PathBuf::from(value("--data")?),
+ "--public-url" => config.public_url = Some(value("--public-url")?),
+ "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
+ "--label" => label = value("--label")?,
+ "--rotate" => rotate = true,
+ s if s.starts_with('-') => return Err(format!("unknown option {s}")),
+ s => words.push(s.to_string()),
+ }
+ }
+ let w: Vec<&str> = words.iter().map(String::as_str).collect();
+ let command = match w.as_slice() {
+ [] | ["serve"] => Command::Serve,
+ ["key"] => Command::Key { rotate },
+ ["files"] | ["files", "list"] => Command::Files,
+ ["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
+ ["links", "list", file] => Command::Links { file: file.to_string() },
+ ["links", "create", file] => {
+ Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
+ }
+ ["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
+ _ => return Err(format!("unknown command `{}`", words.join(" "))),
+ };
+ config.check()?;
+ Ok(Cli { config, command })
+}
+
+/// What a running server writes into its data directory.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Running {
+ pub listen: SocketAddr,
+ pub pid: u32,
+}
+
+/// Note where the server listens (for the command line).
+pub fn write_running(data: &Path, listen: SocketAddr) -> Result<(), String> {
+ let text = serde_json::to_vec_pretty(&Running { listen, pid: std::process::id() }).map_err(|e| e.to_string())?;
+ disk::write_atomic(&data.join(SERVER_JSON), &text).map_err(|e| format!("can't write {SERVER_JSON}: {e}"))
+}
+
+/// `addr` as something this machine can connect to (the loopback address for "every interface").
+fn reachable(addr: SocketAddr) -> SocketAddr {
+ match addr.ip() {
+ IpAddr::V4(ip) if ip.is_unspecified() => SocketAddr::from(([127, 0, 0, 1], addr.port())),
+ IpAddr::V6(ip) if ip.is_unspecified() => SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, addr.port())),
+ _ => addr,
+ }
+}
+
+/// How share links and the publish address name the server: `public_url`, else the listen
+/// address (this machine's network address for "every interface").
+pub fn public_endpoint(cfg: &Config, listen: SocketAddr) -> Result {
+ if let Some(e) = cfg.public()? {
+ return Ok(e);
+ }
+ let ip = if listen.ip().is_unspecified() { figcraft_collab_net::lan_ip().unwrap_or(reachable(listen).ip()) } else { listen.ip() };
+ Ok(Endpoint::plain(SocketAddr::new(ip, listen.port()).to_string()))
+}
+
+/// Run a command other than serving; what to print.
+pub fn run(cli: &Cli) -> Result {
+ let cfg = &cli.config;
+ match &cli.command {
+ Command::Help => Ok(USAGE.to_string()),
+ Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
+ Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
+ Command::Key { rotate } => key(cfg, *rotate),
+ Command::Files => match admin(cfg, Admin::Files)? {
+ AdminReply::Files(files) => Ok(files_table(&files)),
+ other => unexpected(other),
+ },
+ Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
+ AdminReply::Done => Ok(format!("Removed file {file} and its links.\n")),
+ other => unexpected(other),
+ },
+ Command::Links { file } => match admin(cfg, Admin::Links { file: file.clone() })? {
+ AdminReply::Links(links) => Ok(links_table(&links)),
+ other => unexpected(other),
+ },
+ Command::CreateLink { file, access, label } => {
+ match admin(cfg, Admin::CreateLink { file: file.clone(), access: *access, label: label.clone() })? {
+ AdminReply::Link(l) => Ok(new_link(cfg, &l)?),
+ other => unexpected(other),
+ }
+ }
+ Command::Revoke { file, link } => match admin(cfg, Admin::Revoke { file: file.clone(), link: link.clone() })? {
+ AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
+ other => unexpected(other),
+ },
+ }
+}
+
+fn unexpected(reply: AdminReply) -> Result {
+ match reply {
+ AdminReply::Failed { reason } => Err(reason),
+ other => Err(format!("the server answered {other:?}")),
+ }
+}
+
+/// The running server's address, when one holds the data directory.
+fn running(data: &Path) -> Result {
+ let bytes = disk::read_capped(&data.join(SERVER_JSON), 1 << 16).map_err(|e| format!("a server is using {} but {e}", data.display()))?;
+ let r: Running = serde_json::from_slice(&bytes).map_err(|e| format!("{SERVER_JSON} is damaged ({e})"))?;
+ Ok(reachable(r.listen))
+}
+
+/// Do one request: through the running server, or on the data directory.
+fn admin(cfg: &Config, req: Admin) -> Result {
+ match Store::try_open(&cfg.data, cfg.store())? {
+ Some(mut store) => Ok(store.admin(req, &|_| None).reply),
+ None => {
+ let addr = running(&cfg.data)?;
+ let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
+ let replies = manage::request(&Endpoint::plain(addr.to_string()), key.trim(), vec![req], &cfg.net(), Duration::from_secs(5))?;
+ replies.into_iter().next().ok_or_else(|| "the server didn't answer".to_string())
+ }
+ }
+}
+
+/// The address to publish to, with the key.
+fn key(cfg: &Config, rotate: bool) -> Result {
+ let mut store = Store::try_open(&cfg.data, cfg.store())?;
+ let listen = match &store {
+ Some(_) => cfg.addr()?,
+ None => running(&cfg.data)?,
+ };
+ let key = match (store.as_mut(), rotate) {
+ (Some(s), true) => s.rotate_key()?,
+ (Some(s), false) => s.key().to_string(),
+ (None, true) => return Err("stop the server to replace its key".into()),
+ (None, false) => std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?.trim().to_string(),
+ };
+ let public = public_endpoint(cfg, listen)?;
+ let mut out = String::new();
+ if rotate {
+ out.push_str("A new server key: the old one no longer works.\n");
+ }
+ let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
+ let _ = writeln!(out, " {}?key={key}", public.url());
+ let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
+ Ok(out)
+}
+
+fn new_link(cfg: &Config, l: &NewLink) -> Result {
+ let listen = match running(&cfg.data) {
+ Ok(a) if store::lock(&cfg.data)?.is_none() => a,
+ _ => cfg.addr()?,
+ };
+ let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
+ let mut out = String::new();
+ let what = if l.link.access == Access::View { "view" } else { "edit" };
+ let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
+ let _ = writeln!(out, " {}", invite.link());
+ let _ = writeln!(out, " {}", invite.url());
+ let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
+ Ok(out)
+}
+
+fn files_table(files: &[FileInfo]) -> String {
+ if files.is_empty() {
+ return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
+ }
+ let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
+ for f in files {
+ let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
+ }
+ out
+}
+
+fn links_table(links: &[LinkInfo]) -> String {
+ if links.is_empty() {
+ return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
+ }
+ let mut out = String::from("LINK ACCESS CREATED LABEL\n");
+ for l in links {
+ let _ = writeln!(out, "{:<16} {:<6} {:<20} {}", l.id, l.access.id(), date(l.created), l.label);
+ }
+ out
+}
+
+fn elide(s: &str, n: usize) -> String {
+ if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::()) }
+}
+
+fn size(bytes: u64) -> String {
+ match bytes {
+ b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / f64::from(1 << 20)),
+ b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
+ b => format!("{b} B"),
+ }
+}
+
+/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
+pub fn date(secs: u64) -> String {
+ let days = i64::try_from(secs / 86_400).unwrap_or(0);
+ let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
+ // Days to a civil date (proleptic Gregorian).
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z.rem_euclid(146_097);
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let mo = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = yoe + era * 400 + i64::from(mo <= 2);
+ format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
+}
diff --git a/apps/figcraft-server/src/cli_tests.rs b/apps/figcraft-server/src/cli_tests.rs
new file mode 100644
index 0000000..085daea
--- /dev/null
+++ b/apps/figcraft-server/src/cli_tests.rs
@@ -0,0 +1,107 @@
+//! The command line: parsing (flags over the config file), and managing a data directory both
+//! on its own and through the server running on it.
+
+use figcraft_collab::Replica;
+use figcraft_collab::message::Access;
+use figcraft_doc::Document;
+
+use crate::cli::{self, Cli, Command, date, parse};
+use crate::config::Config;
+use crate::store::Store;
+use crate::test_util::{TempDir, config};
+
+fn args(s: &[&str]) -> Vec {
+ s.iter().map(|a| a.to_string()).collect()
+}
+
+fn run(dir: &std::path::Path, command: Command) -> Result {
+ cli::run(&Cli { config: config(dir), command })
+}
+
+/// A data directory with one published file: its id.
+fn with_file(dir: &std::path::Path) -> String {
+ let mut store = Store::open(dir, config(dir).store()).unwrap();
+ let snapshot = Replica::new(Document::new("Board")).snapshot().unwrap();
+ store.publish("Board", &snapshot).unwrap().0.id
+}
+
+#[test]
+fn parsing() {
+ let c = parse(&[]).unwrap();
+ assert_eq!(c.command, Command::Serve);
+ assert_eq!(c.config, Config::default());
+ let c = parse(&args(&["serve", "--listen", "0.0.0.0:47900", "--data", "d", "--public-url", "wss://f.example.com/live/"])).unwrap();
+ assert_eq!((c.config.listen.as_str(), c.config.data.to_str()), ("0.0.0.0:47900", Some("d")));
+ assert_eq!(
+ parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
+ Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
+ );
+ assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
+ assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
+ assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
+ for bad in
+ [&["--nope"][..], &["links", "create", "f1"], &["--listen", "nowhere"], &["--listen"], &["frobnicate"], &["--public-url", "ws://h/?key=k"]]
+ {
+ assert!(parse(&args(bad)).is_err(), "{bad:?}");
+ }
+ // A config file, with flags over it; unknown keys are errors.
+ let dir = TempDir::new("cli-config");
+ let file = dir.path().join("server.toml");
+ std::fs::write(&file, "listen = \"0.0.0.0:47901\"\nmax_files = 7\ncompact_after = 50\n").unwrap();
+ let c = parse(&args(&["--config", file.to_str().unwrap(), "--listen", "127.0.0.1:1"])).unwrap();
+ assert_eq!((c.config.listen.as_str(), c.config.max_files, c.config.compact_after), ("127.0.0.1:1", 7, 50));
+ std::fs::write(&file, "max_files = 0\n").unwrap();
+ assert!(parse(&args(&["--config", file.to_str().unwrap()])).unwrap_err().contains("max_files"));
+ std::fs::write(&file, "colour = \"blue\"\n").unwrap();
+ assert!(parse(&args(&["--config", file.to_str().unwrap()])).is_err());
+}
+
+#[test]
+fn managing_a_directory_on_its_own() {
+ let dir = TempDir::new("cli-offline");
+ let file = with_file(dir.path());
+ let list = run(dir.path(), Command::Files).unwrap();
+ assert!(list.contains(&file) && list.contains("Board"), "{list}");
+ let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
+ assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
+ let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
+ assert_eq!(links.lines().count(), 4, "a header and three links: {links}");
+ let id = links.lines().find(|l| l.contains("Client")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
+ run(dir.path(), Command::Revoke { file: file.clone(), link: id.clone() }).unwrap();
+ assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap_err().contains("no such link"));
+ // The key: printed as the address to publish to; replaced on request.
+ let key = run(dir.path(), Command::Key { rotate: false }).unwrap();
+ assert!(key.contains("ws://127.0.0.1:") && key.contains("?key="), "{key}");
+ let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
+ assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
+ run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
+ assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
+ assert!(run(dir.path(), Command::Links { file }).is_err());
+}
+
+#[test]
+fn managing_through_the_running_server() {
+ let dir = TempDir::new("cli-online");
+ let file = with_file(dir.path());
+ let mut server = crate::start(&config(dir.path())).unwrap();
+ // The directory is the server's now: commands ask it.
+ let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::Edit, label: "Team".into() }).unwrap();
+ assert!(made.contains(&format!("host={}", server.addr())), "links name the running server: {made}");
+ let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
+ assert!(links.contains("Team"), "{links}");
+ let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
+ assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
+ assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
+ assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
+ assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
+ server.stop();
+ // Stopped: the directory is free again.
+ assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
+}
+
+#[test]
+fn dates() {
+ assert_eq!(date(0), "1970-01-01 00:00 UTC");
+ assert_eq!(date(951_782_400), "2000-02-29 00:00 UTC");
+ assert_eq!(date(1_791_986_580), "2026-10-14 14:03 UTC");
+}
diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs
new file mode 100644
index 0000000..c7f4da9
--- /dev/null
+++ b/apps/figcraft-server/src/config.rs
@@ -0,0 +1,121 @@
+//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
+
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use figcraft_collab_net::{Endpoint, NetLimits};
+use serde::Deserialize;
+
+use crate::store::StoreConfig;
+
+/// The port the server listens on unless told otherwise (the app's sessions try it first too).
+pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
+
+/// Everything the server can be told (the TOML file's keys are these names).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Config {
+ /// Where to listen: `127.0.0.1:47900` (this machine only) unless told otherwise.
+ pub listen: String,
+ /// The data directory (files, links, the server key).
+ pub data: PathBuf,
+ /// How share links name this server when it isn't reached at `listen` (a reverse proxy:
+ /// `wss://figcraft.example.com/`).
+ pub public_url: Option,
+ /// Most files kept.
+ pub max_files: usize,
+ /// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
+ /// most 64 MiB).
+ pub max_file_mb: u64,
+ /// Most share links per file.
+ pub max_links: usize,
+ /// Most open connections.
+ pub max_connections: usize,
+ /// Most people in one file at once.
+ pub max_people: usize,
+ /// Compact a file's log into a new snapshot after this many entries…
+ pub compact_after: usize,
+ /// …or once the log is this large (MiB).
+ pub compact_mb: u64,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self {
+ listen: format!("127.0.0.1:{DEFAULT_PORT}"),
+ data: PathBuf::from("figcraft-data"),
+ public_url: None,
+ max_files: 1000,
+ max_file_mb: 64,
+ max_links: 100,
+ max_connections: 512,
+ max_people: 256,
+ compact_after: 1000,
+ compact_mb: 16,
+ }
+ }
+}
+
+impl Config {
+ /// Read a TOML file's settings (unknown keys are errors).
+ pub fn load(path: &Path) -> Result {
+ let text = std::fs::read_to_string(path).map_err(|e| format!("can't read {}: {e}", path.display()))?;
+ toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
+ }
+
+ /// The listen address.
+ pub fn addr(&self) -> Result {
+ self.listen.trim().parse().map_err(|_| format!("`listen` must be an address with a port, like 0.0.0.0:{DEFAULT_PORT}"))
+ }
+
+ /// The public URL, when set: a `ws://` or `wss://` address without a query.
+ pub fn public(&self) -> Result